feat!: experimental Rive runtime backend (iOS + Android)#134
feat!: experimental Rive runtime backend (iOS + Android)#134mfazekas wants to merge 157 commits into
Conversation
95816cf to
4fe9e12
Compare
8485f9f to
9b4acd8
Compare
There was a problem hiding this comment.
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 { ... }
🚫 [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
🚫 [ktlint] standard:if-else-wrapping reported by reviewdog 🐶
Expected a newline
🚫 [ktlint] standard:multiline-if-else reported by reviewdog 🐶
Missing { ... }
🚫 [ktlint] standard:if-else-wrapping reported by reviewdog 🐶
Expected a newline
🚫 [ktlint] standard:multiline-if-else reported by reviewdog 🐶
Missing { ... }
🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Expected newline before '.'
🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)
🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)
🚫 [ktlint] standard:string-template reported by reviewdog 🐶
Redundant curly braces
🚫 [ktlint] standard:max-line-length reported by reviewdog 🐶
Exceeded max line length (140)
🚫 [ktlint] standard:string-template reported by reviewdog 🐶
Redundant curly braces
🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Missing newline before ")"
🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after '{'
🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after ';'
🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline before '}'
🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after '{'
🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after ';'
🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline before '}'
🚫 [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.
🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after '{'
🚫 [ktlint] standard:string-template reported by reviewdog 🐶
Redundant curly braces
🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after ';'
🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline before '}'
🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Expected newline before '.'
🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Expected newline before '.'
🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)
🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)
🚫 [ktlint] standard:max-line-length reported by reviewdog 🐶
Exceeded max line length (140)
🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Missing newline before ")"
🚫 [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 '.'
🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)
🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)
🚫 [ktlint] standard:max-line-length reported by reviewdog 🐶
Exceeded max line length (140)
🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Missing newline before ")"
dd06b3a to
a5855c9
Compare
There was a problem hiding this comment.
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.
🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after '{'
🚫 [ktlint] standard:string-template reported by reviewdog 🐶
Redundant curly braces
🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline after ';'
🚫 [ktlint] standard:statement-wrapping reported by reviewdog 🐶
Missing newline before '}'
🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Expected newline before '.'
🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Expected newline before '.'
🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)
🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)
🚫 [ktlint] standard:max-line-length reported by reviewdog 🐶
Exceeded max line length (140)
🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Missing newline before ")"
🚫 [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 '.'
🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)
🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Argument should be on a separate line (unless all arguments can fit a single line)
🚫 [ktlint] standard:max-line-length reported by reviewdog 🐶
Exceeded max line length (140)
🚫 [ktlint] standard:argument-list-wrapping reported by reviewdog 🐶
Missing newline before ")"
8134a07 to
ec12673
Compare
91e2fb6 to
cfb2ff6
Compare
cfb2ff6 to
44681b5
Compare
f1b851e to
aa2fdf0
Compare
35c6fea to
4f1ab3f
Compare
4e0c53a to
be6d334
Compare
164180e to
7116ac7
Compare
dcdde5e to
0a3d09c
Compare
…ental # Conflicts: # example/package.json # package.json # yarn.lock
Brings in #298 (data-binding thread-safety) + 0.4.12 release. Conflict resolutions: - ios/legacy/HybridViewModel{,Instance}.swift: keep #298's MainThread.run thread-safety on the getters AND the experimental getPropertiesAsync(). - package.json + yarn.lock: react-native-nitro-modules -> 0.35.10 (required by #298's iOS Promise.onMain routing); yarn.lock regenerated via yarn install. - .gitignore: keep both entries. - example/ios/Podfile.lock: kept current; regenerated by pod install (CI build-ios does rm + pod install).
- test-harness-ios-legacy pinned Xcode 16.4, which runners no longer have (it was added before main bumped the shared jobs to 26.3, and main has no legacy job to merge a fix from) -> bump to 26.3 and its simulator os_version 18.6 -> >=26.0 to match the iOS 26 runtimes Xcode 26.3 ships. - Pin all iOS jobs (build-ios, test-harness-ios, -legacy) to macos-15: macos-latest is mid-migrating to macOS 26, whose sim set lacks an iPhone 16 Pro on iOS >=26, which fails the simulator-action boot.
…sh) (#313) Bumps `runtimeVersions.android` 11.4.1 → 11.6.1 on the experimental backend line to fix the intermittent native `SIGSEGV` in `test-harness-android`. Root cause is a single-threaded re-entrancy / iterator-invalidation **use-after-free** in `rive::DataBindContainer::updateDataBinds` (a bind that auto-binds a nested view model re-enters `addDataBind`/`removeDataBind` and invalidates the active iterator). Fixed upstream in rive-runtime [#12649](https://github.com/rive-app/rive-runtime/pull/12649), shipped in rive-android 11.6.1 (11.4.1/11.5.0/11.6.0 are vulnerable). The experimental backend compiles + builds clean against 11.6.1 (no API changes needed). Full investigation: #308. (`main` is unaffected — it already pins 11.6.1 and does not carry the experimental backend.)
Resolves conflicts in ci.yml (kept macos-15 pin comments) and use-rive-trigger.harness.tsx (combined the #230 deterministic-rerender de-flake with the experimental backend's awaitViewReady readiness gating).
…314) Cherry-picks the `fmt`-as-C++17 Podfile workaround onto the experimental backend line so its iOS build survives the Xcode 26.4+ (Apple clang 21) `consteval` error in `fmt` (via RCT-Folly on RN 0.80.3). Same change as #306 (which targets `main`); applies cleanly here and the branch had no prior fmt workaround. Remove once we bump to RN 0.83+ (fix is upstream there).
Merges `main` up to the 0.4.14 release into the iOS experimental branch. No conflicts. Notable changes pulled in: 1. fix(android): don't re-acquire a disposed Rive file on view re-attach (#319) 2. fix(hooks): no-op stale property setter calls after dispose (#316) 3. fix(android): bump rive-android to 11.6.2 (#317) — resolves to 11.6.2 here (up from 11.6.1) On the #319 native fix: this branch has the legacy/new Android backend split, so git's rename detection applied the fix to the `android/src/legacy` variant of `RiveReactNativeView.kt` — which is correct, since the crash is a property of upstream `RiveAnimationView`'s cached `rendererAttributes.resource`. The `android/src/new` backend is a separate `TextureView`/`CommandQueue` implementation with no such code path, so the same tweak neither applies nor is needed there. A re-attach lifecycle bug in the new backend, if any, would be a separate fix. --------- Co-authored-by: Rive Engineering <60802183+rive-engineering@users.noreply.github.com>
…ental # Conflicts: # rive-debug-utils/src/specs/DebugUtils.nitro.ts
… handles (experimental) - Re-apply data binding when the view reloads (artboard/state-machine/file change) — the fresh state machine was left unbound, freezing bound graphics at defaults (verified with the Reload Re-bind reproducer). - Pass alignment into the experimental Fit constructors; it was converted but never wired into draw or pointer mapping, so everything rendered center-aligned (verified with the Alignment Prop reproducer). - Release native command-server handles: delete the old state machine on reload and dispose, close the RiveSurface on surface destroy and dispose, close view-owned bound instances (Auto/ByName) when replaced, close ViewModelInstance handles in HybridViewModelInstance.dispose(), and close the artboard created by defaultArtboardViewModel* when its wrapper is disposed. Measured with the Reload/VMI Handle Leak reproducer: the per-instance leak (~11 kB each) is gone; reload-path growth halved (a ~6 kB/reload residue remains with all client-side deletes succeeding, suspected upstream in rive-android's command server). - Wrappers that alias instances owned elsewhere (RiveView.getViewModelInstance) are marked ownsInstance=false so dispose() never closes a shared handle.
…dy, add main-hop dispose (experimental)
- A failed configure (bad artboardName etc.) previously only logged
natively: the onError prop never fired and awaitViewReady() hung forever,
keeping the pending promise (and the view it retains) alive. The catch
block now reports the error through onError and resolves pending
awaitViewReady() callers with false (verified with the Load Error Path
reproducer: onError fires once and the promise settles).
- Restore the legacy backend's dispose() main-thread hop and make deinit
safe off-main: Nitro can drop the last reference on the JS/GC thread,
where MainActor.assumeIsolated { cleanup() } would trap. deinit now only
captures the resources and finishes on main. Not reproduced in practice
(30 mount/unmount cycles with varied timings plus 30 raw-host-component
cycles with forced GC all pass), so this is hardening per Swift
concurrency semantics rather than a confirmed crash fix.
…ackend The RiveView JS wrapper disposes its HybridObject in the unmount effect (#202), but the experimental HybridRiveView had no dispose() override, so teardown only happened via RiveViewManager.onDropViewInstance. Forward Nitro's dispose() to RiveReactNativeView.dispose(), hopping to the main thread (Nitro calls it on the JS thread; Choreographer is thread-local), and make dispose() idempotent so the onDropViewInstance backstop no-ops. Also settle awaitViewReady() with false on dispose so pending callers don't hang after unmount. Verified on emulator: per unmount, the eager path runs the teardown on main ~10ms before onDropViewInstance, which then early-returns.
The recommended hooks internally used the deprecated sync accessors, which
on the experimental backend block the JS thread on the command queue and
log deprecation warnings users never opted into. They also swallowed
invalid-path errors: the experimental accessors return unvalidated handles
whose sync getters default to 0/""/false, so useRiveNumber('typo', vmi)
yielded value 0 with no error.
- useRiveProperty: seed the value via getValueAsync() (guarded against
overwriting a newer listener emission) and surface rejections as the
hook's error — bad paths now produce error instead of a fabricated
default; write through set() instead of the deprecated value setter.
- useRiveList: length via getLengthAsync (rejections surface as error);
all operations now return Promises via the *Async methods.
BREAKING: UseRiveListResult methods return Promises.
- useViewModelInstance: instance creation is async
(viewModelByNameAsync / defaultArtboardViewModelAsync /
create*InstanceAsync) with cancellation that disposes an instance
resolving after unmount; the intermediate ViewModel wrapper is disposed
after creation (releases the artboard it owns on the experimental
backend). onInit now runs before the instance is published rather than
synchronously during render.
Verified on the Android experimental build: QuickStart onInit + setter
work end-to-end and mounting logs zero [Rive/Deprecation] warnings
(previously several per mount).
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.
… 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.
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/runBlockingwrappers on the non-deprecated API surface.The experimental backend is now the default. Legacy backend files are moved to
ios/legacy/andandroid/src/legacy/(identical to main exceptgetEnums()stub andbackendproperty). New implementations live inios/new/andandroid/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
Without the flag, the experimental backend is used.
What works
viewModelAsync)RiveUIViewon iOS, custom implementation on Android)play()/pause()(iOS: togglesisPaused; Android: fully implemented)getEnums()for introspectiongetPropertyCountAsync/getInstanceCountAsyncKnown limitations
Android
defaultArtboardViewModeldoesn't expose the ViewModel name — pending rive-android#443. This causesmodelName/propertyCount/instanceCountto throw andviewModelAsyncpath validation to be skipped on those instances.replaceViewModelis a no-op (not yet implemented)iOS
reset()only pauses — doesn't actually reset the state machineBoth platforms