feat(hooks): async instance creation via useViewModelInstance({ async: true })#304
Merged
mfazekas merged 19 commits intoJul 10, 2026
Merged
Conversation
This was referenced Jul 1, 2026
fbcdf51 to
53b4aa0
Compare
8ee700d to
238166d
Compare
mfazekas
added a commit
that referenced
this pull request
Jul 7, 2026
- e2e: assert the stable not-found message on every backend and treat error.cause as optional (present only when the backend rejects). The old iOS branch expected a cause unconditionally and failed on the legacy backend, which resolves nil instead of throwing (reproduced on a USE_RIVE_LEGACY build). - useRive: represent 'view not ready yet' as undefined and keep null for failure, matching the useRiveFile convention. Previously the initial null made useViewModelInstanceAsync(riveViewRef) settle to a terminal no-ViewModel state during normal mount, and required: true threw to the error boundary before the view attached. - ios/new + android/new: resolve null from defaultArtboardViewModel* when the artboard has no default ViewModel instead of letting getDefaultViewModelInfo's rejection surface as a raw error for a valid VM-less file (issue #189 fixture; reproduced on-device, new e2e test guards it). - comments: reword the categorical 'both platforms throw' claims — that only describes the new backend; legacy resolves nil/null and the not-found branches are its live path. Verified: jest 74 passed; full iOS harness on the new backend 196 passed; e2e file on the legacy backend 10 passed.
mfazekas
added a commit
that referenced
this pull request
Jul 8, 2026
PR #304 supersedes the in-place async rewrite with a dedicated useViewModelInstanceAsync hook (isLoading state, RiveViewRef bind polling, error causes, companion native fixes) and deprecates this one instead. useRiveProperty and useRiveList stay on the async API.
mfazekas
added a commit
that referenced
this pull request
Jul 8, 2026
- e2e: assert the stable not-found message on every backend and treat error.cause as optional (present only when the backend rejects). The old iOS branch expected a cause unconditionally and failed on the legacy backend, which resolves nil instead of throwing (reproduced on a USE_RIVE_LEGACY build). - useRive: represent 'view not ready yet' as undefined and keep null for failure, matching the useRiveFile convention. Previously the initial null made useViewModelInstanceAsync(riveViewRef) settle to a terminal no-ViewModel state during normal mount, and required: true threw to the error boundary before the view attached. - ios/new + android/new: resolve null from defaultArtboardViewModel* when the artboard has no default ViewModel instead of letting getDefaultViewModelInfo's rejection surface as a raw error for a valid VM-less file (issue #189 fixture; reproduced on-device, new e2e test guards it). - comments: reword the categorical 'both platforms throw' claims — that only describes the new backend; legacy resolves nil/null and the not-found branches are its live path. Verified: jest 74 passed; full iOS harness on the new backend 196 passed; e2e file on the legacy backend 10 passed.
3540770 to
c33740f
Compare
mfazekas
added a commit
that referenced
this pull request
Jul 8, 2026
- e2e: assert the stable not-found message on every backend and treat error.cause as optional (present only when the backend rejects). The old iOS branch expected a cause unconditionally and failed on the legacy backend, which resolves nil instead of throwing (reproduced on a USE_RIVE_LEGACY build). - useRive: represent 'view not ready yet' as undefined and keep null for failure, matching the useRiveFile convention. Previously the initial null made useViewModelInstanceAsync(riveViewRef) settle to a terminal no-ViewModel state during normal mount, and required: true threw to the error boundary before the view attached. - ios/new + android/new: resolve null from defaultArtboardViewModel* when the artboard has no default ViewModel instead of letting getDefaultViewModelInfo's rejection surface as a raw error for a valid VM-less file (issue #189 fixture; reproduced on-device, new e2e test guards it). - comments: reword the categorical 'both platforms throw' claims — that only describes the new backend; legacy resolves nil/null and the not-found branches are its live path. Verified: jest 74 passed; full iOS harness on the new backend 196 passed; e2e file on the legacy backend 10 passed.
c33740f to
0400053
Compare
Closed
2 tasks
mfazekas
added a commit
that referenced
this pull request
Jul 8, 2026
- iOS: index conversions use Int(exactly:) so NaN/inf/overflow inputs return nil instead of trapping (L2). - iOS: blockingAsync throws a RuntimeError when the deprecated blocking API is invoked on the main thread (it would deadlock the MainActor work it waits on) instead of hard-trapping the app (L6); afterUpdate's file-nil transient logs a warning instead of a dev RedBox (L5). - Podspec: drop the dead -DRIVE_EXPERIMENTAL_API from consumer-leaking s.xcconfig - nothing references the define (L4). - Backend opt-out accepts both tokens on both platforms: USE_RIVE_LEGACY=1 and =true each work for the Podfile/env and the Gradle property (L17). - CI: the cocoapods "save cache" steps referenced cache-key, which actions/cache/restore does not output (correct name: cache-primary-key) - the save silently no-oped on every run (L18). Assessed and deliberately not taken: L7-L9 (image-set serialization, multi-touch, pause-gated draws - feature work / needs upstream), L12 (superseded by #304's hook), L16 (shared image rawData memory - needs a per-backend split), L1/L3 (log-handler locking, listener first-emission semantics - low risk, need a design pass).
2675467 to
e5e3f24
Compare
mfazekas
added a commit
that referenced
this pull request
Jul 9, 2026
- e2e: assert the stable not-found message on every backend and treat error.cause as optional (present only when the backend rejects). The old iOS branch expected a cause unconditionally and failed on the legacy backend, which resolves nil instead of throwing (reproduced on a USE_RIVE_LEGACY build). - useRive: represent 'view not ready yet' as undefined and keep null for failure, matching the useRiveFile convention. Previously the initial null made useViewModelInstanceAsync(riveViewRef) settle to a terminal no-ViewModel state during normal mount, and required: true threw to the error boundary before the view attached. - ios/new + android/new: resolve null from defaultArtboardViewModel* when the artboard has no default ViewModel instead of letting getDefaultViewModelInfo's rejection surface as a raw error for a valid VM-less file (issue #189 fixture; reproduced on-device, new e2e test guards it). - comments: reword the categorical 'both platforms throw' claims — that only describes the new backend; legacy resolves nil/null and the not-found branches are its live path. Verified: jest 74 passed; full iOS harness on the new backend 196 passed; e2e file on the legacy backend 10 passed.
e5e3f24 to
5c9890d
Compare
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.
…ty, not just the view ref
On the experimental iOS backend the ViewModel instance and its properties
resolve asynchronously a short time after the view's hybridRef is assigned.
The test read `getViewModelInstance().numberProperty('ypos')` immediately
after waiting only for the ref, so on slower CI runners the property was
occasionally still undefined, failing at `expect(ypos1).toBeDefined()`
(~1 in 4 runs). Reproduced deterministically: at ref-callback time the VMI
is null 40/40, becoming ready shortly after.
Poll with waitFor until the `ypos` property is available before sampling it,
for both the initial mount and the post-switch reconfigure.
…odel property Same race as 53b4aa0 (reconfigure test): the default ViewModel instance and its properties resolve asynchronously a short time after the view's hybridRef is assigned. The test read getViewModelInstance().numberProperty('ypos') right after waiting only for the ref, so the VMI was intermittently still undefined — `expect(vmi).not.toBeNull()` passed (undefined !== null) and the next line threw "Cannot read property 'numberProperty' of undefined". Poll with waitFor until the ypos property is available before sampling it.
The iOS harness job fails when the app crashes at launch (bridge never connects, 15-min timeout), but the existing debug step only dumps os_log — which comes back empty for an early-launch crash. Add a step that collects RiveExample crash reports (.ips/.crash) from the host and simulator DiagnosticReports dirs, prints them to the log, and uploads them as an artifact, so the actual crash stack is visible instead of an empty log.
Address the review findings on the async hook (same issues as #305, with the design refined for this branch's native backends): - Map a rejected artboard lookup to the friendly "Artboard 'X' not found or has no ViewModel" error — both platforms throw on an unknown artboard name rather than resolving undefined. A rejection with no artboardName still propagates unchanged. - Keep the stable "ViewModel instance 'X' not found" message when createInstanceByNameAsync rejects, but attach the native error as cause instead of console.warn-ing it away. - Settle to a terminal { instance: null, isLoading: false } when the source is null (useRiveFile's error state) instead of loading forever; undefined still means loading, mirroring useRiveFile's convention. - QuickStart: surface useRiveFile/useViewModelInstanceAsync errors so a failing onInit no longer yields a silent blank screen. - Add an on-device e2e harness covering the real native rejection paths the Jest mocks can't.
- e2e: assert the stable not-found message on every backend and treat error.cause as optional (present only when the backend rejects). The old iOS branch expected a cause unconditionally and failed on the legacy backend, which resolves nil instead of throwing (reproduced on a USE_RIVE_LEGACY build). - useRive: represent 'view not ready yet' as undefined and keep null for failure, matching the useRiveFile convention. Previously the initial null made useViewModelInstanceAsync(riveViewRef) settle to a terminal no-ViewModel state during normal mount, and required: true threw to the error boundary before the view attached. - ios/new + android/new: resolve null from defaultArtboardViewModel* when the artboard has no default ViewModel instead of letting getDefaultViewModelInfo's rejection surface as a raw error for a valid VM-less file (issue #189 fixture; reproduced on-device, new e2e test guards it). - comments: reword the categorical 'both platforms throw' claims — that only describes the new backend; legacy resolves nil/null and the not-found branches are its live path. Verified: jest 74 passed; full iOS harness on the new backend 196 passed; e2e file on the legacy backend 10 passed.
…sync
The auto-bound ViewModelInstance resolves a short time after the view
ref is assigned and there is no native bind-complete signal yet, so the
one-shot getViewModelInstance() read could settle a terminal
{ instance: null } on fast mounts (reproduced on-device with a raw
hybridRef that exposes the ref in the pre-bind window; the useRive
path happened to win the race). The ref path now polls every 50ms for
up to 5s, aborted on unmount/deps-change; a view with no data binding
still settles null, just late. New e2e + unit tests cover the ref
source path, which previously had none (including the instance-is-not-
disposed-on-unmount invariant).
Also from the review follow-ups:
- attach the native rejection as cause on the artboard not-found error
(parity with instanceNotFoundError)
- preserve non-Error rejection values in the outer catch
- QuickStart: riveViewRef?.play() — buttons are tappable before the
view mounts
- CI: collect iOS crash reports on test-harness-ios-legacy failures
too (the legacy backend is where native crashes reproduced)
… creation The wrapper is hook-internal and on the experimental backend owns the artboard resolved for DefaultForArtboard sources; without an explicit dispose it lingers until JS GC finalizes it.
…c: true }) Replace the separate useViewModelInstanceAsync export with an async param on useViewModelInstance, so the API keeps a single name and the migration is a param flip rather than a rename: - overloads with async: true return the async result; the non-async overloads are @deprecated per-overload ('pass async: true') and will flip to async-by-default in the next major - the sync result gains isLoading (true only while the source is pending), which makes the sync and async result unions structurally identical — call sites don't change shape when the default flips - the async implementation is unchanged and stays in useViewModelInstanceAsync.ts as the internal hook behind the flag; the async param must stay constant for a component's lifetime (it selects between hook implementations) - examples, unit tests, and the e2e harness now call useViewModelInstance(source, { async: true, ... }); repro/regression tests that intentionally ride the sync path carry explicit no-deprecated disables
The legacy backends implemented the *Async lookup APIs as
Promise.async { sync impl }, which moves Rive runtime access onto
Nitro's background pool (Swift concurrency executor / Dispatchers.
Default) — concurrent with the attached views rendering the same
RiveFile on the main thread, with no synchronization in the legacy
runtime. That is the race class TSan confirmed for issue #297, and
useViewModelInstance({ async: true }) routes all instance setup
through these paths.
iOS already main-hopped instance creation (Promise.onMain in
HybridViewModel); this applies the same policy to the file/ViewModel
lookups and to Android, where creation also ran on the pool. Android
uses a shared, never-cancelled main scope: a Promise launched on a
cancelled scope would never settle, so a lookup racing dispose() must
still resolve (the bodies null-guard the disposed file).
On the legacy backend "async" thus means "doesn't block the JS
thread", never "parallel with rendering". Not covered here (same
race class, pre-existing via useRiveList/useRiveProperty): the legacy
list-property operations and iOS legacy property setters still run on
the pool — follow-up.
…roid getViewModelInstance
Two fixes from the second cold review:
- The loading reset moved from the effect into render (adjust-state-
during-render): the effect-time reset let React commit one frame
pairing the new source with the previous instance at isLoading:
false — a mismatch consumer guards can't catch (e.g. <RiveView
file={fileB} dataBind={instanceA}/> reaching native) — and then
disposed that instance while still committed. New unit test captures
committed frames on a source flip and fails on the old behavior
(26 mismatched frames).
- Legacy Android's HybridRiveView.getViewModelInstance() read
controller.stateMachines on the JS thread while the main thread
mutates it during startup (issue #297 race class; iOS already hops
via MainThread.run). Now reads under
runBlocking(Dispatchers.Main.immediate). No deterministic test is
possible for the race; the ref-source e2e tests exercise this path
on the android-legacy CI job.
…sync path
Two more items from the second cold review:
- Params objects built separately widen async to boolean (and the
exported *Params types declare async?: boolean), matching no literal
overload — the compiler rejected the call blaming the *source*
argument, and values of the hook's own exported param types stopped
being accepted. New catch-all overloads (per source type) restore
compatibility; literal async values still resolve to the specific
overloads first, keeping deprecation warnings and required narrowing.
Type-level regression tests gate via yarn tsc.
- The sync (non-async) path labeled a null source isLoading: true
forever — the exact spinner-hang the async docs warn about. It now
settles to a terminal { instance: null, isLoading: false } and, with
required: true, throws — mirroring the async path. Behavior change
on the deprecated path, in the direction of the documented contract.
…logging Remaining small items from the second cold review: - required-mode throws no longer leak the internal hook name (useViewModelInstanceAsync: → useViewModelInstance:), and a null source gets its own message pointing at the upstream hook's error instead of 'ensure the source has a valid ViewModel' - flipping the async param between renders now logs an actionable console.error before React's cryptic hooks-order invariant fires - the VM-less normalization on the new backends logs what it swallows (RiveLog.d) and propagates cancellation instead of eating it - e2e: expectDefined also rejects null (its NonNullable claim was unsound), the resolve-to-null test pins the stable 'not found' message, and android-experimental skips announce themselves - JSDoc: the required example now states that required-narrowing needs a non-nullable source type (nullable sources resolve to the standard overload; the runtime throw still applies)
1aab8b3 to
8859f66
Compare
…VMI getter - expo57-example/jest.config.js scoped its roots to ../example/src, which silently dropped ~20 on-device suites (all of example/__tests__, including this PR's e2e guards) from every harness job. Widen the root to ../example so the full shared suite runs against the Expo app, per the config's own stated intent. - Replace the legacy Android getViewModelInstance main-hop (runBlocking(Dispatchers.Main.immediate), added earlier on this branch) with a main-thread-maintained @volatile snapshot: off-main callers get the last snapshot and schedule a refresh — eventually consistent, which suits the polling consumers (the async hook's ref path, harness waitFor loops). runBlocking JS→main can deadlock if the main thread ever waits on JS under the legacy bridge, and is the prime suspect in the android-legacy harness job timing out on all three attempts; the hazard is real regardless of that verdict.
The per-attempt watchdogs (iOS 300s x 5, Android timeout 300 x 3) were sized for the 4-suite subset that ran while expo57's jest roots excluded example/__tests__. The restored ~25-suite run gets through about 15 suites before the 300s kill lands (exit 137, mid reload-rebind on the observed runs — position, not culprit), so every attempt dies the same way. iOS: 3 attempts x 600s (step timeout 15m -> 35m); Android: timeout 600, job timeout 30m -> 50m.
…anges The snapshot getter's posted-refresh design broke the read-after-bind contract: autoplay-false-trigger's issue #156 guard binds via the dataBind prop and does a one-shot getViewModelInstance() read, which returned the stale (null) snapshot on android-legacy (iOS still reads synchronously). Binding always happens on the main thread, so refresh the snapshot right where it changes (bindViewModelInstance and bindToStateMachine) — a JS read after binding then sees a fresh value with no blocking and no polling needed.
Replace the non-deprecated catch-all overloads with plain deprecated
overloads (no async?: false pin): a params object whose async widened
to boolean — or a value of the exported *Params types — now resolves
to the deprecated overload instead of a warning-free catch-all. Since
async is a member of the params types, such calls compile (no
weak-type failure) and behave per the runtime value; the deprecation
message tells the caller the fix: re-pin with { ...params, async:
true }. This makes deprecation coverage total — any call not provably
async: true is flagged — at the cost of a nudge for already-migrated
callers who kept a widened object, which the message resolves.
The type-compat tests keep pinning that these calls compile and run
async at runtime, now with intentional no-deprecated disables.
A dedicated typetest file asserts which calls resolve to @deprecated overloads, in both directions: calls that must be deprecated carry a no-deprecated disable, and eslint runs typetest files with reportUnusedDisableDirectives: 'error' — so if the resolution ever changes, the now-unused directive fails lint; calls that must stay non-deprecated are written bare and fail no-deprecated directly on regression. @ts-expect-error pins the invalid param combinations, and return-type annotations pin the required-narrowing behavior. Gated by yarn lint + yarn tsc (the jest body is a vessel; jest matches all of __tests__).
Replace the session-invented eslint typetest convention with the tsd infrastructure PR #294 introduces, copied as closely as possible so whichever PR merges second reconciles trivially: tsd ^0.33.0, the 'typetest' script (tsd --typings src/index.tsx), the package.json tsd block (directory src/__tests__, paths/moduleResolution/jsx options), jest testPathIgnorePatterns for *.test-d.ts, eslint + root-tsconfig excludes for *.test-d.ts. The pins live in src/__tests__/useViewModelInstance.test-d.ts as explicit assertions: expectDeprecated for every call that must resolve to a deprecated overload (bare, async: false, widened boolean async, exported param types), expectNotDeprecated for the literal async: true forms including the { ...params, async: true } re-pin, expectType for the required-narrowing behavior, and expectError for invalid param combinations. Verified that tsd resolves deprecation per-overload for call expressions (failure messages name the resolved signature). One deliberate divergence from #294: CI gates 'yarn typetest' in the lint job (theirs wires the script without a CI step) — keep the step when the branches meet.
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.
What
Adds an
async: trueparam touseViewModelInstancethat creates the ViewModelInstance via the non-deprecated*Asyncruntime APIs, resolving off the JS thread. The result gains anisLoadingstate (the sync path reports it too, so both modes share one result shape). The non-async overloads are deprecated per-overload andasync: truebecomes the default in the next major.Why
The sync creation path uses
@deprecatedruntime APIs that block the JS thread. Keeping a single hook name (instead of a separateuseViewModelInstanceAsync) makes migration a param flip, keeps parity with@rive-app/react, and lets the default change at the next major without a rename.Notes
asyncselects between two hook implementations and must stay constant for a component's lifetime (documented on the param; a dev-modeconsole.errorexplains a flip before React's hooks-order invariant fires).async: true— including params objects whoseasyncwidened toboolean, or values of the exported*Paramstypes — resolves to a deprecated overload. The message includes the fix: re-pin with{ ...params, async: true }.yarn typetest,src/__tests__/useViewModelInstance.test-d.ts—expectDeprecated/expectNotDeprecated/expectError/expectType). The wiring mirrors feat: type-safe .riv schema system #294's verbatim (same dep, script, config) so whichever PR merges second reconciles trivially; this PR additionally gates the script in the CI lint job.nullsource settles terminally whileundefinedkeeps loading — mirroringuseRiveFileanduseRive.cause(and swallowed native failures logged); the new backends resolve null for a VM-less default artboard instead of surfacing a raw error (issue Android [RIVE] HybridRiveView afterUpdate No default ViewModel found for artboard error #189 fixture); a RiveViewRef source polls briefly for the view's auto-bound instance instead of settling a one-shot null; the intermediate ViewModel wrapper is disposed after creation; on source change the hook resets during render so no frame ever pairs the new source with the previous (about-to-be-disposed) instance;requiredfailures throw actionable messages (a null source points at the upstream hook's error).getViewModelInstance()no longer traverses controller state from the JS thread — it reads a main-thread-maintained snapshot (refreshed eagerly when binding changes), avoiding both the iOS Crash: Occasional crashes #297 race class and a JS↔mainrunBlockingdeadlock hazard.example/__tests__had been silently dropped — coverage went from 4 to 26 suites) with attempt budgets resized to fit, and crash reports are collected on the legacy iOS job too.{ async: true }.Breaking changes
useRive().riveViewRefisundefined(notnull) until the view is ready;nullnow means failed/detached. MigrateriveViewRef !== nullchecks to!= null(orriveViewRef?.method()), and widen any explicitRiveViewRef | nullannotations.nullsource now settles to{ instance: null, isLoading: false }instead of a perpetualinstance: undefined, andrequired: truethrows for it — matching the async semantics.UseViewModelInstanceResultgained anisLoadingfield (additive for consumers, breaking only for exact-shape type assertions).*Asyncruntime lookups now execute on the main thread — "async" means "doesn't block the JS thread", never "parallel with rendering".Verify
yarn tsc,yarn lint,yarn typetest(tsd),yarn jest(86 passed, incl. committed-frame, overload-compat, and async-flip-guard regression tests). On-device: the full restored harness suite (26 suites / 204 tests) green on all four CI jobs (iOS + Android × experimental + legacy); the legacycausedivergence, the VM-less raw error, and the ref-source race were each reproduced before fixing.