Skip to content

feat(hooks): async useViewModelInstanceAsync (deprecate sync hook)#305

Draft
mfazekas wants to merge 2 commits into
mainfrom
feat/viewmodel-instance-async
Draft

feat(hooks): async useViewModelInstanceAsync (deprecate sync hook)#305
mfazekas wants to merge 2 commits into
mainfrom
feat/viewmodel-instance-async

Conversation

@mfazekas

@mfazekas mfazekas commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

What

Adds useViewModelInstanceAsync, an async replacement for useViewModelInstance built on the non-deprecated *Async runtime APIs (viewModelByNameAsync, defaultArtboardViewModelAsync, createInstanceByNameAsync, createDefaultInstanceAsync, createBlankInstanceAsync).

Why

The sync hook creates instances via @deprecated APIs that access the Rive runtime on the JS thread (it ships with an eslint-disable @typescript-eslint/no-deprecated and a migration TODO). The async variant resolves off-thread and drops the deprecated path.

API

Returns a { instance, isLoading, error } discriminated union mirroring useRiveFile. Same overloads/params as the sync hook including required (throws once resolved to null; undefined while loading). Lifecycle uses useState/useEffect with a cancellation flag and callDispose on deps-change/unmount; onInit runs before the instance is exposed.

Backward compatible: the sync useViewModelInstance is marked @deprecated but kept working. Example screens migrated to the async hook (dataBind={instance} unchanged).

Verify

yarn tsc, yarn lint (0 errors), yarn jest (65 passed incl. 16 new async tests).

Note: same change is also present in #304 (which targets the experimental branch); this PR lands it on main independently.

mfazekas added 2 commits July 1, 2026 10:40
The sync useViewModelInstance creates instances via deprecated runtime
APIs that touch the Rive runtime on the JS thread. Add
useViewModelInstanceAsync built on the non-deprecated `*Async` APIs,
returning a { instance, isLoading, error } discriminated union (mirroring
useRiveFile), with cancellation/disposal on deps change + unmount and
onInit applied before the instance is exposed.

Keeps `required` parity (throws once resolved to null; undefined while
loading). The sync hook is marked @deprecated but left working for
backward compatibility. Migrates the example screens to the async hook.
@mfazekas

mfazekas commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Reviewed with a focus on correctness and on whether a backward-compatible path (evolving useViewModelInstance in place) exists. Each point below was checked against the native implementations in ios/new/ and android/src/new/.

Bugs / behavior

  1. Dead "Artboard not found" branch. In createInstanceAsync (RiveFile path), the friendly Artboard 'X' not found or has no ViewModel error only fires if defaultArtboardViewModelAsync resolves undefined — but neither platform can do that on the name path: iOS HybridRiveFile.swift unconditionally does try await file.createArtboard(artboardName) and Android HybridRiveFile.kt calls Artboard.fromFile(...) with no null check, so a missing artboard name rejects and lands in the outer catch with the raw native message instead. Wrap the artboard lookup in a try/catch (or map rejection to the not-found error there). viewModelByNameAsync does resolve undefined on miss, so that branch is fine.

  2. useRiveFile error → infinite loading. When useRiveFile fails it returns {riveFile: null, isLoading: false, error} permanently; this hook maps a null source to a permanent {isLoading: true}, and with required: true it never throws either (the throw is gated on instance === null && !isLoading). A consumer keying a spinner off this hook's isLoading alone spins forever with no surfaced error. Worth at least a doc note that the upstream file's error must be checked.

  3. QuickStart now swallows onInit failures. The sync hook ran onInit during render, so a throw (e.g. numberProperty('health')! on a missing property) redboxed. The async hook catches it into the error result — and QuickStart.tsx destructures only { instance }, so a broken onInit becomes a silent blank screen. The other migrated examples all read error; QuickStart should too.

  4. Misleading "not found" for real errors (minor, ported from the sync hook): the createInstanceByNameAsync catch only console.warns and then reports ViewModel instance 'X' not found. Note the async native paths propagate exceptions that the sync ones swallowed to null (compare HybridViewModel.kt createInstanceByName vs createInstanceByNameAsync), so genuine runtime failures get mislabeled as "not found" more often here than in the sync template.

Structure

  1. The param-extraction block, CreateInstanceResult, and isRiveViewRef are byte-identical copies from useViewModelInstance.ts, and createInstanceAsync is a near line-for-line fork of createInstance (same error strings, same needsDispose bookkeeping). While both hooks coexist, every fix must land twice. Keeping the sync/async resolvers separate is fair, but the param types, extraction, and guards could live in one shared module.

  2. The permanent hook imports its public param types from the module it deprecates — if the sync hook is ever deleted, the deprecated file survives purely as a type host. Extracting UseViewModelInstance*Params into a neutral module now avoids that inversion.

On a backward-compatible approach

I checked whether useViewModelInstance could simply become async under its existing name. The common pattern would actually survive: the sync result union already includes {instance: undefined} so consumers are type-forced to handle a falsy instance, the property hooks (useRiveNumber etc.) tolerate undefined, and dataBind can arrive on a later commit. But two documented contracts cannot survive async-ification: required: true guarantees a non-null instance on every render (async necessarily yields undefined while loading), and onInit is documented as "called synchronously… before the hook returns". So a separate hook is the right call — in-place conversion would be silently breaking for exactly the consumers relying on those guarantees.

One naming consideration: once the sync hook is removed, the permanent API carries an Async suffix with nothing to contrast against, and it diverges from @rive-app/react's useViewModelInstance. Might be worth planning to re-alias it back to useViewModelInstance in the next major.

Checked and fine

The one-commit window where a just-swapped source still exposes the previous instance is exactly the scenario callDispose's Fabric prop-diffing guard covers (and native retention keeps the underlying object alive); every native create API mints a fresh instance, so StrictMode double-effects can't dispose a shared one; and the intermediate ViewModel wrapper needs no explicit dispose (it holds no per-ViewModel native buffer — same as the sync hook).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant