Skip to content

feat!: experimental Rive runtime backend (iOS + Android)#134

Open
mfazekas wants to merge 173 commits into
mainfrom
feat/rive-ios-experimental
Open

feat!: experimental Rive runtime backend (iOS + Android)#134
mfazekas wants to merge 173 commits into
mainfrom
feat/rive-ios-experimental

Conversation

@mfazekas

@mfazekas mfazekas commented Jan 23, 2026

Copy link
Copy Markdown
Collaborator

Adds a new native backend using Rive's experimental runtime APIs on both iOS and Android. The new backend is async-native — all ViewModel operations go through a CommandQueue, eliminating the need for blockingAsync/runBlocking wrappers on the non-deprecated API surface.

The experimental backend is now the default. Legacy backend files are moved to ios/legacy/ and android/src/legacy/ (identical to main except getEnums() stub and backend property). New implementations live in ios/new/ and android/src/new/. CI runs tests on both backends.

Release-please configured for beta prereleases (0.5.0-beta, published as @next).

Opting into the legacy backend

# iOS
USE_RIVE_LEGACY=1 pod install

# Android — add to gradle.properties
USE_RIVE_LEGACY=true

Without the flag, the experimental backend is used.

What works

  • ViewModel data binding (all property types: number, string, boolean, color, enum, trigger, image, list, artboard)
  • Property listeners (number, string, boolean, color, enum, trigger)
  • Nested ViewModels (viewModelAsync)
  • List operations (add, remove, swap, getInstanceAt)
  • Touch/pointer events (handled automatically by RiveUIView on iOS, custom implementation on Android)
  • play() / pause() (iOS: toggles isPaused; Android: fully implemented)
  • getEnums() for introspection
  • getPropertyCountAsync / getInstanceCountAsync

Known limitations

Android

  • defaultArtboardViewModel doesn't expose the ViewModel name — pending rive-android#443. This causes modelName/propertyCount/instanceCount to throw and viewModelAsync path validation to be skipped on those instances.
  • replaceViewModel is a no-op (not yet implemented)

iOS

  • reset() only pauses — doesn't actually reset the state machine

Both platforms

  • Deprecated legacy APIs (Rive Events, SMI inputs, text runs) are not available in the experimental runtime and throw when called
  • Image and list property listeners are no-ops (not yet available in experimental SDK)

@mfazekas
mfazekas force-pushed the feat/rive-ios-experimental branch 2 times, most recently from 95816cf to 4fe9e12 Compare January 23, 2026 11:23
@mfazekas
mfazekas force-pushed the feat/rive-ios-experimental branch 4 times, most recently from 8485f9f to 9b4acd8 Compare February 9, 2026 10:25
@mfazekas mfazekas changed the title feat: experimental iOS API support (getEnums via SPM) feat: experimental iOS API support Feb 9, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remaining comments which cannot be posted as a review comment to avoid GitHub Rate Limit

ktlint

🚫 [ktlint] standard:multiline-if-else reported by reviewdog 🐶
Missing { ... }

data[2] == 0x54.toByte() && data[3] == 0x4F.toByte()) return AssetType.FONT


🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Expected newline before '.'


🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Expected newline before '.'


🚫 [ktlint] standard:try-catch-finally-spacing reported by reviewdog 🐶
Expected a newline after '{'


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after '{'


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline before '}'


🚫 [ktlint] standard:try-catch-finally-spacing reported by reviewdog 🐶
Expected a newline before '}'


🚫 [ktlint] standard:try-catch-finally-spacing reported by reviewdog 🐶
Expected a newline after '{'


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after '{'


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline before '}'


🚫 [ktlint] standard:try-catch-finally-spacing reported by reviewdog 🐶
Expected a newline before '}'


🚫 [ktlint] standard:no-unused-imports reported by reviewdog 🐶
Unused import


🚫 [ktlint] standard:string-template reported by reviewdog 🐶
Redundant curly braces

Log.d(TAG, "onSurfaceTextureAvailable: ${w}x${h} worker=${this@RiveReactNativeView.riveWorker != null}")


🚫 [ktlint] standard:if-else-wrapping reported by reviewdog 🐶
Expected a newline

val deltaTime = if (lastFrameTimeNs == 0L) Duration.ZERO


🚫 [ktlint] standard:multiline-if-else reported by reviewdog 🐶
Missing { ... }

val deltaTime = if (lastFrameTimeNs == 0L) Duration.ZERO


🚫 [ktlint] standard:if-else-wrapping reported by reviewdog 🐶
Expected a newline

else (frameTimeNanos - lastFrameTimeNs).nanoseconds


🚫 [ktlint] standard:multiline-if-else reported by reviewdog 🐶
Missing { ... }

else (frameTimeNanos - lastFrameTimeNs).nanoseconds


🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Expected newline before '.'

Log.d(TAG, "configure: reload=$reload initialUpdate=$initialUpdate fit=$activeFit surfaceTexture=${surfaceTexture != null} surfaceW=${surfaceWidth} surfaceH=${surfaceHeight}")


🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)

Log.d(TAG, "configure: reload=$reload initialUpdate=$initialUpdate fit=$activeFit surfaceTexture=${surfaceTexture != null} surfaceW=${surfaceWidth} surfaceH=${surfaceHeight}")


🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)

Log.d(TAG, "configure: reload=$reload initialUpdate=$initialUpdate fit=$activeFit surfaceTexture=${surfaceTexture != null} surfaceW=${surfaceWidth} surfaceH=${surfaceHeight}")


🚫 [ktlint] standard:string-template reported by reviewdog 🐶
Redundant curly braces

Log.d(TAG, "configure: reload=$reload initialUpdate=$initialUpdate fit=$activeFit surfaceTexture=${surfaceTexture != null} surfaceW=${surfaceWidth} surfaceH=${surfaceHeight}")


🚫 [ktlint] standard:max-line-length reported by reviewdog 🐶
Exceeded max line length (140)

Log.d(TAG, "configure: reload=$reload initialUpdate=$initialUpdate fit=$activeFit surfaceTexture=${surfaceTexture != null} surfaceW=${surfaceWidth} surfaceH=${surfaceHeight}")


🚫 [ktlint] standard:string-template reported by reviewdog 🐶
Redundant curly braces

Log.d(TAG, "configure: reload=$reload initialUpdate=$initialUpdate fit=$activeFit surfaceTexture=${surfaceTexture != null} surfaceW=${surfaceWidth} surfaceH=${surfaceHeight}")


🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Missing newline before ")"

Log.d(TAG, "configure: reload=$reload initialUpdate=$initialUpdate fit=$activeFit surfaceTexture=${surfaceTexture != null} surfaceW=${surfaceWidth} surfaceH=${surfaceHeight}")


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after '{'

val worker = riveWorker ?: run { Log.w(TAG, "touch: no worker"); return }


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after ';'

val worker = riveWorker ?: run { Log.w(TAG, "touch: no worker"); return }


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline before '}'

val worker = riveWorker ?: run { Log.w(TAG, "touch: no worker"); return }


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after '{'

val smHandle = stateMachineHandle ?: run { Log.w(TAG, "touch: no smHandle"); return }


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after ';'

val smHandle = stateMachineHandle ?: run { Log.w(TAG, "touch: no smHandle"); return }


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline before '}'

val smHandle = stateMachineHandle ?: run { Log.w(TAG, "touch: no smHandle"); return }


🚫 [ktlint] standard:if-else-wrapping reported by reviewdog 🐶
A single line if-statement should be kept simple. The 'THEN' may not be wrapped in a block.

if (w <= 0 || h <= 0) { Log.w(TAG, "touch: invalid surface ${w}x${h}"); return }


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after '{'

if (w <= 0 || h <= 0) { Log.w(TAG, "touch: invalid surface ${w}x${h}"); return }


🚫 [ktlint] standard:string-template reported by reviewdog 🐶
Redundant curly braces

if (w <= 0 || h <= 0) { Log.w(TAG, "touch: invalid surface ${w}x${h}"); return }


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after ';'

if (w <= 0 || h <= 0) { Log.w(TAG, "touch: invalid surface ${w}x${h}"); return }


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline before '}'

if (w <= 0 || h <= 0) { Log.w(TAG, "touch: invalid surface ${w}x${h}"); return }


🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Expected newline before '.'

val legacyFile = app.rive.runtime.kotlin.core.File(bytes)


🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Expected newline before '.'

Log.d("ComposeRiveTest", "[$label] input[$j]: name=${input.name} isBoolean=${input.isBoolean} isTrigger=${input.isTrigger} isNumber=${input.isNumber}")


🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)

Log.d("ComposeRiveTest", "[$label] input[$j]: name=${input.name} isBoolean=${input.isBoolean} isTrigger=${input.isTrigger} isNumber=${input.isNumber}")


🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)

Log.d("ComposeRiveTest", "[$label] input[$j]: name=${input.name} isBoolean=${input.isBoolean} isTrigger=${input.isTrigger} isNumber=${input.isNumber}")


🚫 [ktlint] standard:max-line-length reported by reviewdog 🐶
Exceeded max line length (140)

Log.d("ComposeRiveTest", "[$label] input[$j]: name=${input.name} isBoolean=${input.isBoolean} isTrigger=${input.isTrigger} isNumber=${input.isNumber}")


🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Missing newline before ")"

Log.d("ComposeRiveTest", "[$label] input[$j]: name=${input.name} isBoolean=${input.isBoolean} isTrigger=${input.isTrigger} isNumber=${input.isNumber}")


🚫 [ktlint] standard:function-naming reported by reviewdog 🐶
Function name should start with a lowercase letter (except factory methods) and use camel case


🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Expected newline before '.'

Log.d("ComposeRiveTest", "artboard=${artboard.artboardHandle} sm=${stateMachine.stateMachineHandle} name=${artboard.name} smName=${stateMachine.name}")


🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)

Log.d("ComposeRiveTest", "artboard=${artboard.artboardHandle} sm=${stateMachine.stateMachineHandle} name=${artboard.name} smName=${stateMachine.name}")


🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)

Log.d("ComposeRiveTest", "artboard=${artboard.artboardHandle} sm=${stateMachine.stateMachineHandle} name=${artboard.name} smName=${stateMachine.name}")


🚫 [ktlint] standard:max-line-length reported by reviewdog 🐶
Exceeded max line length (140)

Log.d("ComposeRiveTest", "artboard=${artboard.artboardHandle} sm=${stateMachine.stateMachineHandle} name=${artboard.name} smName=${stateMachine.name}")


🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Missing newline before ")"

Log.d("ComposeRiveTest", "artboard=${artboard.artboardHandle} sm=${stateMachine.stateMachineHandle} name=${artboard.name} smName=${stateMachine.name}")

@mfazekas
mfazekas force-pushed the feat/rive-ios-experimental branch from dd06b3a to a5855c9 Compare February 16, 2026 10:41

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remaining comments which cannot be posted as a review comment to avoid GitHub Rate Limit

ktlint

🚫 [ktlint] standard:if-else-wrapping reported by reviewdog 🐶
A single line if-statement should be kept simple. The 'THEN' may not be wrapped in a block.

if (w <= 0 || h <= 0) { Log.w(TAG, "touch: invalid surface ${w}x${h}"); return }


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after '{'

if (w <= 0 || h <= 0) { Log.w(TAG, "touch: invalid surface ${w}x${h}"); return }


🚫 [ktlint] standard:string-template reported by reviewdog 🐶
Redundant curly braces

if (w <= 0 || h <= 0) { Log.w(TAG, "touch: invalid surface ${w}x${h}"); return }


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after ';'

if (w <= 0 || h <= 0) { Log.w(TAG, "touch: invalid surface ${w}x${h}"); return }


🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline before '}'

if (w <= 0 || h <= 0) { Log.w(TAG, "touch: invalid surface ${w}x${h}"); return }


🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Expected newline before '.'

val legacyFile = app.rive.runtime.kotlin.core.File(bytes)


🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Expected newline before '.'

Log.d("ComposeRiveTest", "[$label] input[$j]: name=${input.name} isBoolean=${input.isBoolean} isTrigger=${input.isTrigger} isNumber=${input.isNumber}")


🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)

Log.d("ComposeRiveTest", "[$label] input[$j]: name=${input.name} isBoolean=${input.isBoolean} isTrigger=${input.isTrigger} isNumber=${input.isNumber}")


🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)

Log.d("ComposeRiveTest", "[$label] input[$j]: name=${input.name} isBoolean=${input.isBoolean} isTrigger=${input.isTrigger} isNumber=${input.isNumber}")


🚫 [ktlint] standard:max-line-length reported by reviewdog 🐶
Exceeded max line length (140)

Log.d("ComposeRiveTest", "[$label] input[$j]: name=${input.name} isBoolean=${input.isBoolean} isTrigger=${input.isTrigger} isNumber=${input.isNumber}")


🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Missing newline before ")"

Log.d("ComposeRiveTest", "[$label] input[$j]: name=${input.name} isBoolean=${input.isBoolean} isTrigger=${input.isTrigger} isNumber=${input.isNumber}")


🚫 [ktlint] standard:function-naming reported by reviewdog 🐶
Function name should start with a lowercase letter (except factory methods) and use camel case


🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Expected newline before '.'

Log.d("ComposeRiveTest", "artboard=${artboard.artboardHandle} sm=${stateMachine.stateMachineHandle} name=${artboard.name} smName=${stateMachine.name}")


🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)

Log.d("ComposeRiveTest", "artboard=${artboard.artboardHandle} sm=${stateMachine.stateMachineHandle} name=${artboard.name} smName=${stateMachine.name}")


🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)

Log.d("ComposeRiveTest", "artboard=${artboard.artboardHandle} sm=${stateMachine.stateMachineHandle} name=${artboard.name} smName=${stateMachine.name}")


🚫 [ktlint] standard:max-line-length reported by reviewdog 🐶
Exceeded max line length (140)

Log.d("ComposeRiveTest", "artboard=${artboard.artboardHandle} sm=${stateMachine.stateMachineHandle} name=${artboard.name} smName=${stateMachine.name}")


🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Missing newline before ")"

Log.d("ComposeRiveTest", "artboard=${artboard.artboardHandle} sm=${stateMachine.stateMachineHandle} name=${artboard.name} smName=${stateMachine.name}")

@mfazekas
mfazekas force-pushed the feat/rive-ios-experimental branch from 8134a07 to ec12673 Compare February 17, 2026 12:11
@mfazekas mfazekas changed the title feat: experimental iOS API support WIP feat: experimental iOS / Android POC Feb 17, 2026
@mfazekas
mfazekas force-pushed the feat/rive-ios-experimental branch 2 times, most recently from 91e2fb6 to cfb2ff6 Compare February 19, 2026 13:50
@mfazekas
mfazekas force-pushed the feat/rive-ios-experimental branch from cfb2ff6 to 44681b5 Compare February 26, 2026 13:57
@mfazekas
mfazekas force-pushed the feat/rive-ios-experimental branch 2 times, most recently from f1b851e to aa2fdf0 Compare March 16, 2026 09:39
@mfazekas
mfazekas force-pushed the feat/rive-ios-experimental branch 4 times, most recently from 35c6fea to 4f1ab3f Compare March 27, 2026 15:52
@mfazekas
mfazekas changed the base branch from main to feat/hooks-undefined-initial-value March 27, 2026 15:52
@mfazekas
mfazekas force-pushed the feat/rive-ios-experimental branch from 4e0c53a to be6d334 Compare March 30, 2026 12:39
@mfazekas
mfazekas force-pushed the feat/hooks-undefined-initial-value branch from 164180e to 7116ac7 Compare March 30, 2026 18:17
Base automatically changed from feat/hooks-undefined-initial-value to main March 30, 2026 18:20
@mfazekas
mfazekas force-pushed the feat/rive-ios-experimental branch from dcdde5e to 0a3d09c Compare March 31, 2026 05:33
@mfazekas mfazekas changed the title WIP feat: experimental iOS / Android POC feat: experimental Rive runtime backend (iOS + Android) Mar 31, 2026
@mfazekas
mfazekas marked this pull request as ready for review March 31, 2026 07:31
@mfazekas mfazekas changed the title feat: experimental Rive runtime backend (iOS + Android) feat!: experimental Rive runtime backend (iOS + Android) Mar 31, 2026
@mfazekas
mfazekas requested a review from HayesGordon March 31, 2026 14:18
mfazekas added 22 commits July 8, 2026 08:23
… active

yarn release would bypass release-please and publish a stable to the
latest dist-tag. The 0.5.0-beta version bump itself needs no config
change: both merge strategies for #134 carry a breaking marker (the PR
title 'feat!:' for a squash, the 'feat(hooks)!:' commit for a merge
commit) and bump-minor-pre-major turns that into the minor bump.
runtimeVersions.{ios,android} live in package.json, which none of the
harness app-binary keys (nor the legacy cocoapods key) hashed — bumping
only the runtime pin produced an exact cache hit and the job silently
tested the old RiveRuntime/rive-android.
…e (experimental)

- Android: RiveErrorLogger.addListener is idempotent — views re-register
  on every reload, and duplicates fanned the same error out N times to
  onError (review M11).
- Android: auto-binding probes getDefaultViewModelInfo instead of
  checking the created instance's handle against the magic value 1L,
  which relied on undocumented handle allocation (review M4).
- iOS: configure() applies isPaused = !autoPlay synchronously before
  launching the config task, and the task no longer recomputes it — a
  play()/pause() issued while configure is in flight (common with
  autoPlay={false} + play() in an effect) is no longer clobbered when
  the task completes (review M10).
… handles

The property accessors cannot validate paths on the experimental backend
(lookup happens on the async command server), so a bad path returns a
handle and surfaces on use instead of as an undefined return. State this
on the accessor docs with the migration guidance (getPropertiesAsync /
getValueAsync rejection / hook error).

Restoring the legacy nullable contract with a blocking probe in the
accessors was considered and rejected: the accessors are not deprecated
— the hooks create properties through them on every mount, and a
runBlocking round-trip there would reintroduce the JS-thread stall the
async hooks migration just removed.
…fast path; opt-in JS log forwarding

- legacy: setValueAsync wrote property.value inside Promise.async (a
  background task), bypassing the MainThread.run discipline every other
  legacy mutation uses — races the main-thread render loop (review M12).
- experimental: value/trigger listener tasks captured self strongly for
  their infinite stream loop, so raw-API consumers dropping a property
  with an active listener leaked wrapper + instance + JS callback (the
  deinit cancelAll was unreachable). Capture the instance/prop locals
  instead (review M6).
- experimental: a dataBind-only prop change with a live Rive instance now
  re-binds the running state machine instead of rebuilding artboard +
  state machine, which visually restarted the animation (review M9).
- RiveLog no longer installs a JS console handler as an import side
  effect (every consumer paid a JSI round-trip per native log line, and
  console.error raised dev RedBoxes for conditions apps can't act on).
  Forwarding is opt-in via RiveLog.forwardToConsole(), and resetHandler()
  now actually detaches the JS handler via the native resetHandler
  instead of re-installing the JS default (review M13). Also turn the
  vacuous no-op-handler harness test into a real guard (review L14).
…perimental

The experimental command server hands out handles/enqueues for any index:
getInstanceAt(999) returned a truthy wrapper whose operations silently
no-op, and addInstanceAt/swap/removeInstanceAt resolved on enqueue even
out of range (review M5). The async paths now probe the list size -
out-of-range lookups return null/nil like the legacy backend and
out-of-range mutations reject.

useRiveList also bumps its revision after its own mutations: the
experimental backend has no native list-change notifications (addListener
is a no-op), so the length result never refreshed after add/remove/swap
there.
…n, deprecated-throws coverage

- getPropertiesAsync on instances without ViewModel metadata (nested
  paths, list items, view-obtained) now rejects with a clear message
  instead of resolving [] - "no metadata" must not read as "no
  properties" (review M7).
- Spec docs: the nine data-binding-deprecated RiveView methods state
  that they throw on the experimental backend (review M14); instanceName
  documents the experimental "" limitation (review M8, needs upstream
  SDK support for a real fix); getEnums and getPropertiesAsync document
  their legacy-backend behavior; getBackend() is typed
  'legacy' | 'experimental' (review L11).
- README gains a Runtime Backends section: default backend, the durable
  USE_RIVE_LEGACY opt-out per platform (Podfile global vs env-var
  pitfall), and the behavioral differences.
- docs/riv-files.md: drop the stale experimental-crash claims - those
  files are exercised green by the harness suites CI runs on the
  experimental backend (review M15).
- view-methods harness: cover that the deprecated SMI-input, text-run,
  and event methods actually throw on the experimental backend - the
  headline behavioral break previously had zero coverage.
…tal)

Referenced assets are registered globally by bare name on the shared
worker/command queue and were never unregistered, so decoded image/font/
audio bytes accumulated for the app's lifetime (review M1). registerAssets
now returns the assets it registered so the owning HybridRiveFile can
release them (unregister + close on Android, removeGlobal* on iOS) in its
dispose().

The name-global registry itself is an upstream design: two live files with
an asset of the same name still clobber each other (last registration
wins) - per-file namespacing needs SDK support.
- 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).
… build timing (#324)

Adds an `expo57-example` workspace (Expo SDK 57 / RN 0.86) and makes it
the default iOS build on CI, using Expo's precompiled React Native
XCFrameworks
(https://expo.dev/blog/faster-ios-builds-with-precompiled-xcframeworks)
plus the EAS prebuilt third-party bucket (reanimated/worklets;
unofficial endpoint with automatic source fallback).

1. `build-ios` now builds expo57-example with caching (exact-key
short-circuit, Pods cache, incremental build-dir cache). Cold xcodebuild
is ~8 min vs ~26 min median for the bare example; timing details in the
comments below.
2. `test-harness-expo57-ios` runs the shared harness suite against the
Expo app. This needed the `unstable_path` asset publicPath (out-of-root
shared assets) and a yarn patch on `@react-native-harness/bundler-metro`
— its entry-point swap didn't recognize Expo's
server-root-relative/virtual-entry requests, so the real app booted
instead of the harness host (worth upstreaming).
3. The bare RN 0.79 example keeps its coverage via the existing
`test-harness-ios`/`-legacy` jobs, which build it themselves.
4. expo57-example drops the create-expo-app theme boilerplate; its
workspace typecheck is clean.

Verified locally (app renders, Quick Start data binding works, all 4
harness suites pass) and on CI (all jobs green).
- load-error.harness: mounting with a bad artboardName must reach onError
  (both backends) and awaitViewReady() must settle instead of hanging
  (experimental; the resolved value is timing/platform-dependent, so only
  settlement is asserted).
- reload-rebind.harness: bouncing_ball's state machine streams ypos into
  the bound instance, so a live binding is observable as listener events;
  after an artboardName reload with an unchanged dataBind prop the stream
  must continue.

Both verified discriminating on the Android emulator: reload-rebind fails
with the H3 fix reverted (stream stops after reload) and passes with it;
load-error's onError assertion covers the H2 silent-failure mode.
…ceName SDK gap

The legacy backend exists for behavior comparison during the experimental
rollout, not as an end-user configuration - move the Runtime Backends
section from the README to docs/runtime-backends.md (linked from
CONTRIBUTING) and frame it accordingly.

instanceName: state that the experimental runtimes do not expose instance
names yet (verified through rive-android 11.7.0 bytecode - only the per-VM
name list exists) and that SDK support has been requested, rather than
implying a permanent limitation.
…: true }) (#304)

## What

Adds an `async: true` param to `useViewModelInstance` that creates the
ViewModelInstance via the non-deprecated `*Async` runtime APIs,
resolving off the JS thread. The result gains an `isLoading` state (the
sync path reports it too, so both modes share one result shape). The
non-async overloads are deprecated per-overload and `async: true`
becomes the default in the next major.

## Why

The sync creation path uses `@deprecated` runtime APIs that block the JS
thread. Keeping a single hook name (instead of a separate
`useViewModelInstanceAsync`) makes migration a param flip, keeps parity
with `@rive-app/react`, and lets the default change at the next major
without a rename.

## Notes

- `async` selects between two hook implementations and must stay
constant for a component's lifetime (documented on the param; a dev-mode
`console.error` explains a flip before React's hooks-order invariant
fires).
- Deprecation coverage is total: any call not provably `async: true` —
including params objects whose `async` widened to `boolean`, or values
of the exported `*Params` types — resolves to a deprecated overload. The
message includes the fix: re-pin with `{ ...params, async: true }`.
- Overload/deprecation resolution is regression-pinned with tsd (`yarn
typetest`, `src/__tests__/useViewModelInstance.test-d.ts` —
`expectDeprecated`/`expectNotDeprecated`/`expectError`/`expectType`).
The wiring mirrors #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.
- In both modes, a `null` source settles terminally while `undefined`
keeps loading — mirroring `useRiveFile` and `useRive`.
- Companion fixes: friendly artboard/instance not-found errors with the
native rejection preserved as `cause` (and swallowed native failures
logged); the new backends resolve null for a VM-less default artboard
instead of surfacing a raw error (issue #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; `required` failures throw actionable messages (a null source
points at the upstream hook's error).
- Legacy Android thread-safety: `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 #297 race class and a JS↔main `runBlocking`
deadlock hazard.
- CI: the expo57 harness now runs the full shared suite
(`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.
- Example screens migrated to `{ async: true }`.

## Breaking changes

- `useRive().riveViewRef` is `undefined` (not `null`) until the view is
ready; `null` now means failed/detached. Migrate `riveViewRef !== null`
checks to `!= null` (or `riveViewRef?.method()`), and widen any explicit
`RiveViewRef | null` annotations.
- On the sync (non-async, deprecated) path a `null` source now settles
to `{ instance: null, isLoading: false }` instead of a perpetual
`instance: undefined`, and `required: true` throws for it — matching the
async semantics.
- `UseViewModelInstanceResult` gained an `isLoading` field (additive for
consumers, breaking only for exact-shape type assertions).
- Legacy backends: the `*Async` runtime 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 legacy `cause` divergence, the VM-less raw error, and the
ref-source race were each reproduced before fixing.
#333)

Ports 6e55131 and 84e6d26 from PR #331 (android/src/main) to the legacy
copy of RiveReactNativeView.kt: clear the `lastKnownViewModelInstance`
snapshot on the disposing detach and resolve null from the main-thread
read once `willDispose` is set, so post-dispose reads return null
instead of a released instance; and reuse a cached main-thread Handler
instead of allocating one per off-main `getViewModelInstance()` call.
The file is now byte-identical to main's fixed copy.
`loadCdn` is silently ignored on the experimental backend (its
CommandQueue file-load API has no CDN asset resolution). This makes
`loadCdn` optional and adds a one-time `console.warn` only when a caller
*explicitly* passes `loadCdn: true` on that backend, pointing them to
`referencedAssets`; the common case (no `loadCdn`, e.g. `useRiveFile`)
is unaffected. Context: #15.
The android harness jobs flakily die with "App bridge disconnected" /
"[birpc] rpc is closed", but the failure-path debug step has never
captured the actual crash: its `-s ReactNativeJS:* RNRive:*` filter
silences AndroidRuntime and DEBUG/libc, it never reads the crash buffer,
and it runs after android-emulator-runner has already killed the
emulator, so `adb logcat` has nothing to talk to.

This adds `scripts/collect-android-crash-logs.sh` and calls it from
inside the retry loop (while the emulator is still alive): after each
failed attempt it dumps the crash buffer plus an unfiltered logcat (per
attempt, since the ring buffer rotates across retries), and after the
final attempt it pulls `/data/tombstones` via `adb root` (kept out of
the per-attempt path because the adbd restart could break the next
attempt's connection). Collected files are uploaded as
`android-harness-crash-logs` / `android-harness-legacy-crash-logs`
artifacts, mirroring the iOS crash-report steps.
…e API

createRiveSurface now takes an owned CloseableSurface instead of a raw
SurfaceTexture; wrap the TextureView's texture in SurfaceTextureSurface
like the SDK's own RiveView does.
…l backend (#341)

rive-android 11.7.x's new command-queue API supports Vulkan rendering
behind `app.rive.RenderBackend`, defaulting to OpenGL. This surfaces
that choice: `RiveRuntime.setAndroidRenderBackend('vulkan' | 'opengl')`
stores a process-wide preference that the experimental backend passes to
the shared `CommandQueue` when it is created on first file load. Calling
it after the worker exists logs a warning and is ignored; on iOS and on
the legacy Android backend it is a no-op (legacy logs a warning for
'vulkan'). Vulkan requires API 29+ and rive-android falls back to OpenGL
automatically if init fails.

Verified on the example app (Pixel 6 emulator, API 34): with the call,
logcat shows `Created CommandQueue (renderBackend=Vulkan)` and the Quick
Start page renders and updates normally on goldfish Vulkan; without it
the default remains `renderBackend=OpenGL`. Both source sets compile;
typecheck, jest, eslint, and ktlint pass.

On-device comparison (OnePlus CPH2719, Dimensity 8300 / Mali-G615,
Android 16, 90Hz; 6 continuously animating rewards.riv views, A/B/A
runs): identical throughput and 0% jank on both backends, but Vulkan
used ~145% total app CPU vs ~162-164% on OpenGL. The Rive worker thread
dropped from ~70% to ~52% and the Mali driver threads from ~37% to ~22%
(HWUI RenderThread rose slightly, 19% to 25%). Net: same rendering for
roughly 11% less CPU — a modest efficiency/battery gain, consistent with
Vulkan's lower driver overhead.
#344)

Adds an optional `semantics` view prop (`Semantics.Off` / `On` /
`Automatic`, default `Off`) that maps to `RiveUIView.semantics` on the
experimental iOS backend, exposing accessibility semantics authored in
the Rive editor (roles, labels, selection state) to VoiceOver
([docs](https://rive.app/docs/runtimes/apple/semantics)). Semantics-only
prop changes are applied to the live view without rebuilding the
artboard/state machine.

Semantics exist in the new Rive runtime only, so the legacy iOS backend
accepts the prop and ignores it; both Android backends do the same until
upstream rive-android ships semantics support.

Includes a `Semantics (VoiceOver)` example page using `tabtest.riv` from
rive-runtime's semantic test assets. Verified on an iPhone 17 / iOS 26.5
simulator: with semantics on, the three tabs are exposed as
accessibility buttons with the selected trait, and switching to off
removes them.

Known upstream quirk found while verifying: re-enabling semantics on a
live view (off → on) restarts the SDK's semantics manager (`Enabling
semantics` + diff drains are logged) but the accessibility elements
don't repopulate until the view is remounted — looks like the core
doesn't re-deliver a full tree diff after a re-enable.
@HayesGordon

Copy link
Copy Markdown
Contributor

Posting a link to our internal discussion for future reference: https://2dimensions.slack.com/archives/C09MY1ACH5M/p1781013135867559

mfazekas added 6 commits July 17, 2026 13:12
…nds (#347)

Adds an optional `frameRate` view prop (issue #332): a number caps the
render loop at that many frames per second, and a `{ minimum, maximum,
preferred? }` range maps to `CAFrameRateRange` on iOS; Android honors a
range best-effort as a cap at `preferred ?? maximum`. Capping limits
frame production, not animation time — playback still advances by the
real elapsed time, so wall-clock speed is unchanged.

iOS forwards the prop to `RiveUIView.frameRate`
(https://rive.app/docs/runtimes/apple/apple#frame-rate). Android's new
runtime has no public per-view API (`RiveFramePacer` is internal and
only drives the Compose loop), so our Choreographer loop skips vsyncs
itself — same math as upstream's pacer — and on API 35+ also applies the
cap as the platform `requestedFrameRate` hint. While paused, the Android
loop now only draws when content changed (initial frame, resize,
rebinding) instead of re-rendering identical frames every vsync. The
legacy backends accept the prop and ignore it.

Also includes the null-prop postprocess fix from #326 (clearing
`frameRate` back to undefined throws without it); dedupes trivially with
whichever lands first.

Before/after on a looping animation (the Frame Rate Cap example page
added here), app process CPU on a Pixel 6 emulator (API 34, % of one
core, 12s windows, cold start, idle host):

| frameRate | before | after |
|---|---|---|
| undefined | 37% | 36% |
| 30 | no effect | 17% |
| 15 | no effect | 9% |
| 5 | 37% | 6% |
| paused | 36% | 3% |

The paused row is the paused-draw fix: before, a paused view kept
rendering identical frames at the display refresh rate. Effective frame
production was verified independently: `dumpsys gfxinfo` counts
285/151/75/25 frames per 5s for uncapped/30/15/5 and 0 while paused; on
the iOS simulator, unique frames in a screen recording measured ~58 fps
uncapped, 5 fps at `frameRate={5}` and 15 fps for `{minimum: 10,
maximum: 20, preferred: 15}`. Earlier real-device measurements of the
same cap logic (OnePlus Dimensity 8350, debug build): uncapped 133% of a
core, cap=30 111%, cap=15 68%, paused 14% ≈ the app's cost with Rive
unmounted (13%).
…log as a LogBox error (#348)

Opening any Rive page in the Debug example app pops a LogBox red screen
reading `[RiveUIView] [RiveUIView] Draw failed: missing drawable`. The
SDK's `RiveUIView` force-draws its MTKView before Auto Layout has sized
it, so `currentDrawable` is nil for the first frame(s); it logs that
transient condition at error level and rendering recovers on the next
tick. rive-ios 6.21.1 is the latest release and upstream `main` has no
change here, so `RiveRuntimeLogger` now forwards this specific `.view`
message at debug instead of error.

Also strips the tag prefix the SDK hardcodes into its message strings
(it already passes the same tag separately), which caused the doubled
`[RiveUIView] [RiveUIView]` above.

Verified on an iPhone 17 / iOS 26.5 simulator: unfixed build shows the
red screen on Quick Start; with the fix the same `CAMetalLayer
nextDrawable returning nil` still occurs in the device log but nothing
is forwarded at error level, no LogBox appears, and the animation
renders.
…4+ (#349)

RN 0.80's bundled fmt 11.0.2 fails to compile on Xcode 26.4+ (Apple
clang 21 rejects fmt's consteval `FMT_STRING` path, expo/expo#44229).
The `FMT_USE_CONSTEVAL=0` build setting the Podfile used never took
effect: fmt 11.0.2 predates the `#ifdef` escape hatch (added in fmt
11.1) and unconditionally re-defines the macro.

Instead of patching fmt sources, force fmt 12.1.0 — the version RN 0.86
ships, where the consteval code is fixed upstream — by rewriting the
vendored `fmt.podspec` and `RCT-Folly.podspec` (which exact-pins fmt)
before dependency resolution, and dropping stale `Pods/Local Podspecs`
JSONs that CocoaPods would otherwise reuse. First install after this
change needs `pod update fmt` or `rm Podfile.lock` (CI already deletes
the lock). Drop the whole block once the example is on RN >= 0.83.

Verified on Xcode 26.5: full example build succeeds and the iOS harness
suite passes (26 suites / 204 tests).
Restoring node_modules from a different yarn.lock era and running an
incremental install over it can strip bin exec bits; the broken tree then
gets re-cached under the current exact key, failing every subsequent run.
Drop the cross-lockfile restore-key fallback and repair any non-executable
bin targets after install.
play-after-autoplay-false came from main (#343, a legacy fix) and drives
the state machine via SMI inputs, which the experimental backends reject
by design.

@HayesGordon HayesGordon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thanks for all the hard work on this! Excited to get this in.

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.

3 participants