Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ca35c66
feat(hooks): add async useViewModelInstanceAsync, deprecate sync hook
mfazekas Jul 1, 2026
0c9ea23
test(harness): fix flaky reconfigure test — wait for ViewModel proper…
mfazekas Jul 1, 2026
684f933
test(harness): fix flaky autoplay auto-dataBind test — wait for ViewM…
mfazekas Jul 3, 2026
a8720c4
ci(ios): capture simulator crash reports on iOS harness failure
mfazekas Jul 3, 2026
d5643bb
fix(hooks): surface native failures from useViewModelInstanceAsync
mfazekas Jul 7, 2026
8d7f63b
fix: backend-divergence issues from the #304 review
mfazekas Jul 7, 2026
217b319
fix(hooks): poll for the view-bound instance in useViewModelInstanceA…
mfazekas Jul 7, 2026
1877cc1
fix(hooks): dispose the intermediate ViewModel wrapper after instance…
mfazekas Jul 8, 2026
7db22bb
feat(hooks)!: fold the async variant into useViewModelInstance({ asyn…
mfazekas Jul 8, 2026
d3b17e3
fix(legacy): run *Async file/ViewModel lookups on the main thread
mfazekas Jul 9, 2026
dec621d
fix(hooks): reset during render on source change; main-hop legacy And…
mfazekas Jul 9, 2026
8430822
fix(hooks): accept widened async params; terminal null source on the …
mfazekas Jul 9, 2026
8859f66
fix(hooks): actionable messages, guarded async flip, specific native …
mfazekas Jul 9, 2026
8e82ea0
fix(ci+android): restore the full harness suite; non-blocking legacy …
mfazekas Jul 9, 2026
7677a56
ci: resize harness attempt budgets for the restored full suite
mfazekas Jul 9, 2026
cf99b1d
fix(android-legacy): refresh the VMI snapshot eagerly when binding ch…
mfazekas Jul 10, 2026
80912d0
refactor(hooks): route widened async params to the deprecated overloads
mfazekas Jul 10, 2026
8b8c6f2
test(hooks): type-level pins for overload deprecation resolution
mfazekas Jul 10, 2026
dae84b0
test(hooks): migrate deprecation pins to tsd, matching PR #294's wiring
mfazekas Jul 10, 2026
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
107 changes: 95 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ jobs:
- name: Typecheck files
run: yarn typecheck

# Overload/deprecation pins (src/__tests__/*.test-d.ts). PR #294 adds
# the same tsd wiring without gating it; keep this step when merging.
- name: Type tests
run: yarn typetest

test:
runs-on: ubuntu-latest
steps:
Expand Down Expand Up @@ -369,7 +374,7 @@ jobs:

- name: Run harness tests on iOS
working-directory: expo57-example
timeout-minutes: 15
timeout-minutes: 35
shell: bash --noprofile --norc -o pipefail {0}
env:
# First attempt bundles through the harness Metro on app boot; CI
Expand Down Expand Up @@ -433,12 +438,12 @@ jobs:
BOOTED_UDID=$(xcrun simctl list devices booted | grep -oE '[0-9A-F-]{36}' | head -1)
echo "booted simulator: ${BOOTED_UDID:-none}"

for attempt in 1 2 3 4 5; do
echo "=== Attempt $attempt of 5 ==="
for attempt in 1 2 3; do
echo "=== Attempt $attempt of 3 ==="
start_monitor
yarn test:harness:ios --verbose --testTimeout 120000 &
test_pid=$!
( sleep 300; echo " WATCHDOG: attempt $attempt exceeded 300s, killing harness"; kill -9 $test_pid 2>/dev/null ) &
( sleep 600; echo " WATCHDOG: attempt $attempt exceeded 600s, killing harness"; kill -9 $test_pid 2>/dev/null ) &
watchdog_pid=$!
wait $test_pid 2>/dev/null
exit_code=$?
Expand All @@ -465,9 +470,48 @@ jobs:
echo "=== Simulator logs (last 5m) ==="
xcrun simctl spawn booted log show --predicate 'processImagePath CONTAINS "expo57example"' --last 5m --style compact 2>&1 | tail -200 || echo "No logs found"

- name: Debug - Collect iOS crash reports
if: failure() || cancelled()
run: |
set +e
UDID=$(xcrun simctl list devices | awk -F '[()]' '/Booted/{print $2; exit}')
echo "Booted simulator UDID: ${UDID:-<none>}"
mkdir -p crash-reports
# Simulator app crashes are written to the host's DiagnosticReports by
# ReportCrash, and sometimes inside the simulator's own data dir. Grab
# RiveExample-named reports plus anything from the last 25 min as a fallback.
for dir in \
"$HOME/Library/Logs/DiagnosticReports" \
"$HOME/Library/Developer/CoreSimulator/Devices/$UDID/data/Library/Logs/DiagnosticReports"; do
[ -d "$dir" ] || continue
find "$dir" -type f \
\( -iname 'RiveExample-*' -o \( \( -iname '*.ips' -o -iname '*.crash' \) -mmin -25 \) \) \
-exec cp {} crash-reports/ \; 2>/dev/null
done
count=$(ls -1 crash-reports 2>/dev/null | wc -l | tr -d ' ')
echo "=== Collected ${count} crash report(s) ==="
for f in crash-reports/*; do
[ -e "$f" ] || continue
echo "---------------- ${f} ----------------"
cat "$f"
echo
done
if [ "${count}" = "0" ]; then
echo "No crash reports found (app may not have crashed, or reports were not flushed yet)."
fi
exit 0

- name: Upload iOS crash reports
if: failure() || cancelled()
uses: actions/upload-artifact@v4
with:
name: ios-harness-crash-reports
path: crash-reports/
if-no-files-found: ignore

test-harness-android:
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 50
env:
ANDROID_API_LEVEL: 35
steps:
Expand Down Expand Up @@ -556,7 +600,7 @@ jobs:
script: |
adb install expo57-example/android/app/build/outputs/apk/debug/app-debug.apk
sleep 10
cd expo57-example && for attempt in 1 2 3; do echo "Attempt $attempt of 3"; if timeout 300 env ANDROID_AVD=test yarn test:harness:android --verbose --testTimeout 120000; then echo "Tests passed on attempt $attempt"; exit 0; fi; echo "Attempt $attempt failed (exit $?), retrying..."; sleep 5; done; echo "All attempts failed"; exit 1
cd expo57-example && for attempt in 1 2 3; do echo "Attempt $attempt of 3"; if timeout 600 env ANDROID_AVD=test yarn test:harness:android --verbose --testTimeout 120000; then echo "Tests passed on attempt $attempt"; exit 0; fi; echo "Attempt $attempt failed (exit $?), retrying..."; sleep 5; done; echo "All attempts failed"; exit 1

- name: Debug - Check logcat
if: failure() || cancelled()
Expand Down Expand Up @@ -664,7 +708,7 @@ jobs:

- name: Run harness tests on iOS
working-directory: expo57-example
timeout-minutes: 15
timeout-minutes: 35
shell: bash --noprofile --norc -o pipefail {0}
env:
# First attempt bundles through the harness Metro on app boot; CI
Expand Down Expand Up @@ -728,12 +772,12 @@ jobs:
BOOTED_UDID=$(xcrun simctl list devices booted | grep -oE '[0-9A-F-]{36}' | head -1)
echo "booted simulator: ${BOOTED_UDID:-none}"

for attempt in 1 2 3 4 5; do
echo "=== Attempt $attempt of 5 ==="
for attempt in 1 2 3; do
echo "=== Attempt $attempt of 3 ==="
start_monitor
yarn test:harness:ios --verbose --testTimeout 120000 &
test_pid=$!
( sleep 300; echo " WATCHDOG: attempt $attempt exceeded 300s, killing harness"; kill -9 $test_pid 2>/dev/null ) &
( sleep 600; echo " WATCHDOG: attempt $attempt exceeded 600s, killing harness"; kill -9 $test_pid 2>/dev/null ) &
watchdog_pid=$!
wait $test_pid 2>/dev/null
exit_code=$?
Expand All @@ -760,9 +804,48 @@ jobs:
echo "=== Simulator logs (last 5m) ==="
xcrun simctl spawn booted log show --predicate 'processImagePath CONTAINS "expo57example"' --last 5m --style compact 2>&1 | tail -200 || echo "No logs found"

- name: Debug - Collect iOS crash reports
if: failure() || cancelled()
run: |
set +e
UDID=$(xcrun simctl list devices | awk -F '[()]' '/Booted/{print $2; exit}')
echo "Booted simulator UDID: ${UDID:-<none>}"
mkdir -p crash-reports
# Simulator app crashes are written to the host's DiagnosticReports by
# ReportCrash, and sometimes inside the simulator's own data dir. Grab
# RiveExample-named reports plus anything from the last 25 min as a fallback.
for dir in \
"$HOME/Library/Logs/DiagnosticReports" \
"$HOME/Library/Developer/CoreSimulator/Devices/$UDID/data/Library/Logs/DiagnosticReports"; do
[ -d "$dir" ] || continue
find "$dir" -type f \
\( -iname 'RiveExample-*' -o \( \( -iname '*.ips' -o -iname '*.crash' \) -mmin -25 \) \) \
-exec cp {} crash-reports/ \; 2>/dev/null
done
count=$(ls -1 crash-reports 2>/dev/null | wc -l | tr -d ' ')
echo "=== Collected ${count} crash report(s) ==="
for f in crash-reports/*; do
[ -e "$f" ] || continue
echo "---------------- ${f} ----------------"
cat "$f"
echo
done
if [ "${count}" = "0" ]; then
echo "No crash reports found (app may not have crashed, or reports were not flushed yet)."
fi
exit 0

- name: Upload iOS crash reports
if: failure() || cancelled()
uses: actions/upload-artifact@v4
with:
name: ios-harness-legacy-crash-reports
path: crash-reports/
if-no-files-found: ignore

test-harness-android-legacy:
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 50
env:
ANDROID_API_LEVEL: 35
steps:
Expand Down Expand Up @@ -858,7 +941,7 @@ jobs:
script: |
adb install expo57-example/android/app/build/outputs/apk/debug/app-debug.apk
sleep 10
cd expo57-example && for attempt in 1 2 3; do echo "Attempt $attempt of 3"; if timeout 300 env ANDROID_AVD=test yarn test:harness:android --verbose --testTimeout 120000; then echo "Tests passed on attempt $attempt"; exit 0; fi; echo "Attempt $attempt failed (exit $?), retrying..."; sleep 5; done; echo "All attempts failed"; exit 1
cd expo57-example && for attempt in 1 2 3; do echo "Attempt $attempt of 3"; if timeout 600 env ANDROID_AVD=test yarn test:harness:android --verbose --testTimeout 120000; then echo "Tests passed on attempt $attempt"; exit 0; fi; echo "Attempt $attempt failed (exit $?), retrying..."; sleep 5; done; echo "All attempts failed"; exit 1

- name: Debug - Check logcat
if: failure() || cancelled()
Expand Down
17 changes: 11 additions & 6 deletions android/src/legacy/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 All @@ -140,7 +145,7 @@ class HybridRiveFile : HybridRiveFileSpec() {

override fun getEnums(): Promise<Array<RiveEnumDefinition>> {
val file = riveFile ?: return Promise.resolved(emptyArray())
return Promise.async {
return Promise.async(riveMainScope) {
try {
file.enums
.map { enum ->
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
Original file line number Diff line number Diff line change
Expand Up @@ -57,23 +57,25 @@ class HybridViewModel(private val viewModel: ViewModel) : HybridViewModelSpec()
return Promise.rejected(UnsupportedOperationException("getPropertiesAsync is not supported on the legacy backend"))
}

// 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/legacy/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())
30 changes: 29 additions & 1 deletion android/src/legacy/java/com/rive/RiveReactNativeView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,23 @@ 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? {
val stateMachines = riveAnimationView?.controller?.stateMachines
return if (!stateMachines.isNullOrEmpty()) {
stateMachines.first().viewModelInstance
Expand All @@ -176,6 +190,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 +385,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
12 changes: 11 additions & 1 deletion android/src/new/java/com/margelo/nitro/rive/HybridRiveFile.kt
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,21 @@ class HybridRiveFile(
Artboard.fromFile(file)
}
val vmSource = ViewModelSource.DefaultForArtboard(artboard)
// getDefaultViewModelInfo throws when the artboard has no default
// ViewModel — a normal state for VM-less files (issue #189 fixture), not
// a failure. Resolve null like the legacy backend so callers get the
// documented "no ViewModel" result — but propagate cancellation and log
// what was swallowed so a genuine failure (disposed file, dead command
// queue) isn't a silently blank screen.
val vmInfo = try {
file.getDefaultViewModelInfo(artboard)
} catch (e: Exception) {
} catch (e: kotlinx.coroutines.CancellationException) {
runCatching { artboard.close() }
throw e
} catch (e: Exception) {
RiveLog.d(TAG, "defaultArtboardViewModel: resolving null — getDefaultViewModelInfo failed: ${e.message}")
runCatching { artboard.close() }
return null
}
return HybridViewModel(file, riveWorker, vmInfo.viewModelName, this, vmSource, ownedArtboard = artboard)
}
Expand Down
3 changes: 3 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ 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/',
Expand Down
14 changes: 11 additions & 3 deletions example/__tests__/autoplay.harness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -397,9 +397,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