Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ jobs:
- name: Typecheck files
run: yarn typecheck

# Overload/deprecation pins (src/__tests__/*.test-d.ts).
- name: Type tests
run: yarn typetest

test:
runs-on: ubuntu-latest
steps:
Expand Down
15 changes: 10 additions & 5 deletions android/src/main/java/com/margelo/nitro/rive/HybridRiveFile.kt
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,13 @@ class HybridRiveFile : HybridRiveFileSpec() {
}
}

// The *Async lookups run on the main thread (via [riveMainScope], not Nitro's
// Dispatchers.Default pool): they walk the same RiveFile the attached views
// render on the main thread, and the legacy runtime has no internal
// synchronization — off-main access is the race class of issue #297.
// "Async" here means "doesn't block the JS thread".
override fun getViewModelNamesAsync(): Promise<Array<String>> {
return Promise.async {
return Promise.async(riveMainScope) {
val file = riveFile ?: return@async emptyArray()
val count = file.viewModelCount
val names = mutableListOf<String>()
Expand All @@ -103,19 +108,19 @@ class HybridRiveFile : HybridRiveFileSpec() {
}

override fun viewModelByNameAsync(name: String, validate: Boolean?): Promise<HybridViewModelSpec?> {
return Promise.async { viewModelByName(name) }
return Promise.async(riveMainScope) { viewModelByName(name) }
}

override fun defaultArtboardViewModelAsync(artboardBy: ArtboardBy?): Promise<HybridViewModelSpec?> {
return Promise.async { defaultArtboardViewModel(artboardBy) }
return Promise.async(riveMainScope) { defaultArtboardViewModel(artboardBy) }
}

override fun getArtboardCountAsync(): Promise<Double> {
return Promise.async { artboardCount }
return Promise.async(riveMainScope) { artboardCount }
}

override fun getArtboardNamesAsync(): Promise<Array<String>> {
return Promise.async { artboardNames }
return Promise.async(riveMainScope) { artboardNames }
}

override fun updateReferencedAssets(referencedAssets: ReferencedAssetsType) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() {
}

override fun getViewModelInstance(): HybridViewModelInstanceSpec? {
// Thread-safety lives in RiveReactNativeView.getViewModelInstance(): it
// returns a main-thread-maintained snapshot without blocking this thread.
val viewModelInstance = view.getViewModelInstance() ?: return null
return HybridViewModelInstance(viewModelInstance)
}
Expand Down
12 changes: 7 additions & 5 deletions android/src/main/java/com/margelo/nitro/rive/HybridViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,23 +53,25 @@ class HybridViewModel(private val viewModel: ViewModel) : HybridViewModelSpec()
}
}

// Main-hopped like HybridRiveFile's lookups — the legacy runtime is only
// safe to touch on the main thread (see riveMainScope).
override fun getPropertyCountAsync(): Promise<Double> {
return Promise.async { propertyCount }
return Promise.async(riveMainScope) { propertyCount }
}

override fun getInstanceCountAsync(): Promise<Double> {
return Promise.async { instanceCount }
return Promise.async(riveMainScope) { instanceCount }
}

override fun createInstanceByNameAsync(name: String): Promise<HybridViewModelInstanceSpec?> {
return Promise.async { createInstanceByName(name) }
return Promise.async(riveMainScope) { createInstanceByName(name) }
}

override fun createDefaultInstanceAsync(): Promise<HybridViewModelInstanceSpec?> {
return Promise.async { createDefaultInstance() }
return Promise.async(riveMainScope) { createDefaultInstance() }
}

override fun createBlankInstanceAsync(): Promise<HybridViewModelInstanceSpec?> {
return Promise.async { createInstance() }
return Promise.async(riveMainScope) { createInstance() }
}
}
13 changes: 13 additions & 0 deletions android/src/main/java/com/margelo/nitro/rive/RiveMainScope.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.margelo.nitro.rive

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob

// Legacy runtime objects are only safe to touch on the main thread: the
// attached views render there and rive-android's legacy API has no internal
// synchronization (off-main access is the race class of issue #297). This
// scope hops *Async calls onto main. It is intentionally never cancelled —
// a Promise launched on a cancelled scope would never settle, so bodies
// guard on disposed state instead.
internal val riveMainScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
37 changes: 36 additions & 1 deletion android/src/main/java/com/rive/RiveReactNativeView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
if (willDispose) {
riveAnimationView?.dispose()
removeEventListeners()
// Post-dispose reads must resolve null, not the retained (released)
// instance; also stops pinning it for the view's remaining lifetime.
lastKnownViewModelInstance = null
}
super.onDetachedFromWindow()
}
Expand Down Expand Up @@ -165,9 +168,27 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
if (!stateMachines.isNullOrEmpty()) {
stateMachines.first().viewModelInstance = vmi
}
// Keep the snapshot fresh at the moment binding changes so a JS-side
// read right after binding sees the instance without waiting for a
// posted refresh (guards the read-after-bind contract, issue #156).
lastKnownViewModelInstance = readViewModelInstanceOnMain()
}

fun getViewModelInstance(): ViewModelInstance? {
// Cache maintained on the main thread: the controller's state-machine list
// is mutated there and the legacy runtime has no internal synchronization
// (issue #297 race class), so other threads must not traverse it. Off-main
// reads return the last main-thread snapshot and schedule a refresh —
// eventually consistent, which suits the polling callers (the async hook's
// ref path, harness waitFor loops). Blocking on the main thread instead
// would risk a JS↔main deadlock under the legacy bridge.
@Volatile
private var lastKnownViewModelInstance: ViewModelInstance? = null

private fun readViewModelInstanceOnMain(): ViewModelInstance? {
// A refresh posted before dispose() can run after it; reading the
// torn-down controller's stale list here would re-cache a released
// instance right after onDetachedFromWindow cleared it.
if (willDispose) return null
val stateMachines = riveAnimationView?.controller?.stateMachines
return if (!stateMachines.isNullOrEmpty()) {
stateMachines.first().viewModelInstance
Expand All @@ -176,6 +197,17 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
}
}

fun getViewModelInstance(): ViewModelInstance? {
if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) {
lastKnownViewModelInstance = readViewModelInstanceOnMain()
return lastKnownViewModelInstance
}
android.os.Handler(android.os.Looper.getMainLooper()).post {
lastKnownViewModelInstance = readViewModelInstanceOnMain()
}
return lastKnownViewModelInstance
}

fun applyDataBinding(bindData: BindData) {
bindToStateMachine(bindData)
}
Expand Down Expand Up @@ -360,6 +392,9 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
}
}
}
// Keep the snapshot fresh at the moment binding changes — see
// lastKnownViewModelInstance.
lastKnownViewModelInstance = readViewModelInstanceOnMain()
}

private fun convertEventProperties(properties: Map<String, Any>?): Map<String, EventPropertiesOutput>? {
Expand Down
6 changes: 6 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export default defineConfig([
'lib/',
'**/.expo/',
'**/.harness/',
// tsd owns *.test-d.ts (yarn typetest) — deprecated calls there are
// intentional expectDeprecated() pins, not violations.
'**/*.test-d.ts',
// Agent worktrees (.claude/worktrees/*) are gitignored full-repo copies;
// linting them duplicates every finding and slows the run.
'**/.claude/',
],
},
]);
14 changes: 11 additions & 3 deletions example/__tests__/autoplay.harness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,9 +302,17 @@ describe('Auto dataBind with no default ViewModel (issue #189)', () => {

expect(context.error).toBeNull();

const vmi = context.ref!.getViewModelInstance();
expect(vmi).not.toBeNull();
expect(vmi!.numberProperty('ypos')).toBeDefined();
// The default ViewModel instance and its properties resolve asynchronously a
// short time after the view ref is assigned, so poll until the property is
// available rather than reading it the instant the ref exists.
await waitFor(
() => {
expect(
context.ref!.getViewModelInstance()?.numberProperty('ypos')
).toBeDefined();
},
{ timeout: 5000 }
);

cleanup();
});
Expand Down
Loading
Loading