feat(hooks): async instance creation via useViewModelInstance({ async: true })#331
Merged
Merged
Conversation
…: true }) Port of #304 from feat/rive-ios-experimental to main. The instance is created via the *Async runtime APIs off the JS thread; the hook returns { instance, isLoading, error } and the sync overloads are deprecated per-overload. Natives main-hop the *Async lookups (Android riveMainScope, iOS Promise.onMain) and Android's getViewModelInstance() returns a main-thread-maintained snapshot instead of traversing the controller off-thread (issue #297 race class).
The ported comments compared "the new backend" to "the legacy backend", which only reads on feat/rive-ios-experimental where both exist. Reword so a reader of this branch knows the divergent behavior lives on the experimental backend and why the hook still handles it here.
lastKnownViewModelInstance survived dispose, so a JS caller still holding the view ref got the retained, already-released instance back (first property access throws "Cannot acquire a disposed object") instead of null. Clear it on the disposing detach, and resolve null from the main-thread read once willDispose is set so a refresh posted before dispose can't re-cache a released instance afterwards.
There was a problem hiding this comment.
Pull request overview
This PR ports the “async ViewModelInstance creation” work onto main, adding an async: true mode to useViewModelInstance that uses the non-deprecated *Async runtime APIs to avoid blocking the JS thread, while deprecating the synchronous overloads. It also aligns null/undefined loading semantics across hooks and hardens native thread-safety around legacy runtime access (notably related to the #297 race class).
Changes:
- Add
useViewModelInstance(source, { async: true })returning{ instance, isLoading, error }, with per-overload deprecations for the sync path plus tsd pins and CI gating. - Update native iOS/Android
*Asynclookup paths to hop to the main thread; AndroidgetViewModelInstance()becomes snapshot-based off-main. - Migrate example usage and add unit + e2e harness coverage for async behavior, overload resolution, and loading/null semantics.
Reviewed changes
Copilot reviewed 28 out of 31 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| yarn.lock | Adds tsd and dependency updates needed for type tests. |
| tsconfig.json | Excludes **/*.test-d.ts from normal TS builds. |
| package.json | Adds typetest script, tsd config, and ignores .test-d.ts in Jest. |
| .github/workflows/ci.yml | Runs yarn typetest in CI. |
| eslint.config.mjs | Ignores *.test-d.ts (owned by tsd) and .claude/ worktrees. |
| src/index.tsx | Re-exports updated hook result types and adds required-result type export. |
| src/hooks/useViewModelInstanceAsync.ts | New internal async implementation backing async: true behavior. |
| src/hooks/useViewModelInstance.ts | Adds async?: boolean param, async dispatch + overload/deprecation strategy, and isLoading. |
| src/hooks/useRive.ts | Changes riveViewRef initial state to undefined (pending) and widens the type. |
| src/hooks/tests/useViewModelInstance.test.ts | Updates null/undefined source semantics assertions for sync path. |
| src/hooks/tests/useViewModelInstance.async.test.tsx | Adds comprehensive unit tests for async mode behavior + invariants. |
| src/hooks/tests/useRive.test.ts | Adds a unit test for riveViewRef being undefined before ready. |
| src/tests/useViewModelInstance.test-d.ts | Adds tsd pins for overload/deprecation/required narrowing behavior. |
| ios/HybridRiveFile.swift | Moves *Async lookups to Promise.onMain for legacy thread-safety. |
| android/src/main/java/com/margelo/nitro/rive/RiveMainScope.kt | Adds a main-thread coroutine scope for legacy-safe async operations. |
| android/src/main/java/com/margelo/nitro/rive/HybridRiveFile.kt | Hops *Async lookups onto main via riveMainScope. |
| android/src/main/java/com/margelo/nitro/rive/HybridViewModel.kt | Hops *Async ViewModel APIs onto main via riveMainScope. |
| android/src/main/java/com/rive/RiveReactNativeView.kt | Implements main-thread snapshotting for getViewModelInstance() and clears cache on detach. |
| android/src/main/java/com/margelo/nitro/rive/HybridRiveView.kt | Documents thread-safety delegation to RiveReactNativeView.getViewModelInstance(). |
| example/src/reproducers/Issue297ThreadRace.tsx | Migrates to useViewModelInstance(..., { async: true }). |
| example/src/exercisers/RiveDataBindingExample.tsx | Migrates to async: true. |
| example/src/exercisers/NestedViewModelExample.tsx | Migrates to async: true. |
| example/src/exercisers/MenuListExample.tsx | Migrates to async: true. |
| example/src/exercisers/FontFallbackExample.tsx | Migrates to async: true. |
| example/src/demos/DataBindingArtboardsExample.tsx | Migrates to async: true. |
| example/src/demos/QuickStart.tsx | Migrates to async: true, adds error UI, and uses optional chaining for ref play. |
| example/src/tests/staleSetterWrite.test.tsx | Adds explicit lint suppression to keep a sync-path regression test. |
| example/src/tests/issue297.hooks.test.tsx | Adds explicit lint suppression to keep a sync-path #297 regression test. |
| example/tests/autoplay.harness.tsx | Polls for auto-bound properties instead of reading immediately after ref attach. |
| example/tests/useViewModelInstance-async-e2e.harness.tsx | Adds native-backed harness tests covering async hook contract across backends. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The async hook's ref path polls getViewModelInstance() every 50ms; allocating a Handler per off-main call was avoidable churn.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Port of #304 (merged to
feat/rive-ios-experimental) tomain.useViewModelInstance(source, { async: true })creates the instance via the*Asyncruntime APIs off the JS thread and returns{ instance, isLoading, error }. The sync overloads are deprecated per-overload (they block the JS thread via deprecated runtime APIs);async: truebecomes the default in the next major. Overload/deprecation resolution is pinned with tsd (yarn typetest, gated in the CI lint job).Native side: the
*Asynclookups now hop to the main thread (AndroidriveMainScope, iOSPromise.onMain) since the legacy runtime has no internal synchronization (the #297 race class), and Android'sgetViewModelInstance()returns a main-thread-maintained snapshot instead of traversing the controller off-thread, without blocking JS.Porting notes:
legacy/sources map onto this branch's flat layout (android/src/main,ios/); thenew/-backend and expo57 harness changes from feat(hooks): async instance creation via useViewModelInstance({ async: true }) #304 don't apply here.src/hooks/useViewModelInstance.tsand the ported example files are taken from the experimental branch (re-run through main's prettier), which also brings a small pre-feat(hooks): async instance creation via useViewModelInstance({ async: true }) #304 improvement:createInstanceByNamefailures on the sync path nowconsole.warnand resolve null instead of throwing raw native errors.RiveFileFactory.getBackend()doesn't exist here) and adds thearbtboards-models-instances.rivfixture.Behavioral notes (worth a release-note callout):
riveViewRef.getViewModelInstance()is now eventually consistent off the main thread: it returns the last main-thread snapshot and schedules a refresh, instead of the previous unsynchronized live read (the iOS Crash: Occasional crashes #297 race). One-shot readers — including the deprecated syncuseViewModelInstance(riveViewRef)— can observenullwhere the racy read happened to catch a late auto-bind; theasync: truepath polls and is unaffected.useRive().riveViewRefnow starts asundefined(view pending) instead ofnull(failed/detached), mirroring theuseRiveFileconvention, and its type widens toRiveViewRef | null | undefined.nullsource now settles to a terminal{ instance: null, isLoading: false }instead of reporting undefined/loading forever.Docs: rive-app/rive-docs#797 (draft until this ships).