Skip to content

fix: don't throw when an optional RiveView prop is cleared back to undefined#326

Open
mfazekas wants to merge 141 commits into
mainfrom
fix/optional-prop-clear-null
Open

fix: don't throw when an optional RiveView prop is cleared back to undefined#326
mfazekas wants to merge 141 commits into
mainfrom
fix/optional-prop-clear-null

Conversation

@mfazekas

@mfazekas mfazekas commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Setting an optional RiveView prop back to undefined (e.g. artboardName from a string to no value) threw Exception in HostFunction: RiveView.artboardName: Value is null, expected a String during React commit. Fabric's prop diff sends null (not undefined) when a prop is removed, and Nitro's JSIConverter<std::optional<T>> only maps undefined to nullopt, so the inner converter throws. This affects all seven optional props (artboardName, stateMachineName, autoPlay, alignment, fit, layoutScaleFactor, dataBind) and both Android backends — the generated prop parser is shared C++, so iOS is affected the same way.

The fix extends scripts/nitrogen-postprocess.ts to patch the generated HybridRiveViewComponent.cpp, treating null like undefined at the optional-prop parse sites. This is a workaround until it's fixed upstream in Nitro (mrousavy/nitro#1184, open fix PR mrousavy/nitro#1189); the postprocess step already runs as part of yarn nitrogen, so regeneration keeps the fix.

Includes a reproducer page (example/src/reproducers/OptionalPropClear.tsx) that sets each optional prop and then clears it: it failed 7/7 before the fix and passes 7/7 after, verified on the Android emulator with both the new and the legacy (USE_RIVE_LEGACY=true) backend.

mfazekas and others added 30 commits June 1, 2026 11:19
Add new experimental iOS backend (ios/new/) with synchronous API,
move legacy backend files to ios/legacy/, add getEnums() support,
retry listener streams on missingData, and restore TestComponentOverlay.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
getEnums() in legacy now throws directing users to the experimental
backend instead of creating throwaway Worker+File instances.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
… binding

Each Worker has its own C++ command server with its own m_artboards handle map.
Creating separate Workers per file meant artboard handles from one file were
invalid on another file's server. Using a shared singleton Worker fixes cross-file
artboard property set. Also wires fit/alignment through experimental Fit enum and
improves asset type detection with audio/font magic bytes.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Happy <yesreply@happy.engineering>
Passing fit to the Rive() constructor breaks layout mode because
the MTKView drawable isn't ready yet. Set rive.fit after
setupRiveUIView() instead.
Port Flutter data binding tests for VM access, enums, creation
variants, list properties, artboard and image properties. Includes
new .riv test assets and react-native-harness upgrade to alpha.25.
…ceByIndex

Prevents fatal crash when passing negative numbers to Swift APIs
expecting unsigned integers.
CocoaPods doesn't embed SPM-resolved dynamic frameworks automatically.
Patches the embed script to include RiveRuntime when USE_RIVE_SPM=1.
Move 19 backend-specific files to src/legacy/java/, add Gradle sourceSets
switching via USE_RIVE_NEW_API property, prepare empty experimental dirs.
Uses the new handle-based Rive Android SDK (app.rive.*) with CommandQueue,
path-based ViewModelInstance property access, and Kotlin Flows for reactivity.
Throws UnsupportedOperationException for SMI inputs, text runs, and events.
Also fixes Gradle property resolution to use rootProject.findProperty.
Custom TextureView renderer with Choreographer render loop, CommandQueue
polling infrastructure, ViewModel resolution and property validation,
and example Compose test activities for manual testing.
Also fix viewModelByIndex/ByName on Android legacy to catch SDK
exceptions instead of letting them propagate as JniExceptions.
…fferences

Fix colorProperty setter Double→Int overflow on Android legacy
(toLong().toInt()). Make enum and replaceViewModel tests tolerant
of Android SDK behavioral differences.
Use .viewModelDefault(from: .name(vmName)) for default instance
creation instead of artboard-based lookup that only worked for
the default artboard's VM. Skip instanceName, color get/set, and
non-existent property checks on experimental iOS where the SDK
doesn't support them.
…anceName

Add async variants for viewModelByName, defaultArtboardViewModel, and
ViewModel create methods. Pass instanceName through at creation time on
both iOS and Android experimental backends as a workaround for the SDK
not exposing ViewModelInstance.name.
…rimental iOS

Color.argbValue is now public in rive-ios 6.15.2 — implement getValue via
blockingAsync, addListener via valueStream, and fix setter crash by using
UInt32(bitPattern:) for negative ARGB values from JS.
- Always use SPM for RiveRuntime (remove CocoaPods fallback)
- Add RiveSPMEmbedFix module to auto-embed RiveRuntime.framework
- Pin SPM to exactVersion instead of upToNextMajorVersion
- Replace USE_RIVE_SPM with USE_RIVE_EXPERIMENTAL_RUNTIME to select
  legacy vs experimental backend independently of SPM
…erimental iOS test issues

- Rename USE_RIVE_EXPERIMENTAL_RUNTIME to USE_RIVE_NEW_API (matches Android)
- Remove USE_RIVE_SPM and all SPM embedding hacks from podspec/Podfile
- Use standard CocoaPods dependency for RiveRuntime
- Emit initial value in experimental addListener (number/string/bool/enum/color)
- Guard tests that crash on experimental iOS (list ops, autoPlay, artboard/image loading)
- Handle createInstanceByName throwing on experimental backend
mfazekas and others added 26 commits June 1, 2026 11:19
Fixes flaky autoplay harness tests. The 6.20.1 fix for "massive
timestamp delta on first frame" was causing the animation to jump
through its entire cycle in one frame, making ypos property change
detection unreliable. Verified with 100 consecutive test runs (0
failures).
Adds 15 new harness tests covering listener callback invocation, set() +
getValueAsync() round-trips, and removeListeners() for all ViewModel
property types. iOS native coverage goes from 41% to 50%.

Target types: BooleanProperty (16→84%), ColorProperty (14→84%),
EnumProperty (16→84%), TriggerProperty (0→81%), StringProperty (37→84%),
NumberProperty (85→90%).
Adds harness tests for two previously untested areas:

**Font config** (6 tests): loadFont by system name, loadFont by URL,
systemFallback, setFallbackFonts with default/weight-specific fonts,
clearFallbackFonts, error on invalid font name.

**Asset loading** (4 tests): referencedAssets with font type, image URL
type, multiple asset types, and undefined assets.

Covers HybridRiveFontConfig.swift (was 0%), AssetLoader.swift (was 1%),
DataSourceResolver.swift (was 0%), HTTPDataLoader.swift (was 43%).
Adds 7 new tests covering previously untested native code paths:

- **fromBytes**: load .riv from raw ArrayBuffer (HybridRiveFileFactory)
- **RiveImages.loadFromURLAsync**: load image and verify byteSize
(HybridRiveImageFactory + HybridRiveImage)
- **RiveLog.setLogLevel**: exercise all 4 log levels (HybridRiveLogger)
- **RiveView pause/play/reset**: exercise view control methods with a
rendered RiveView (HybridRiveView)
Upgrades harness from 1.2.0 to 1.3.0. Key improvements from harness PRs
#129 and #132:

- Bridge RPC closes immediately on app disconnect instead of waiting for
timeout
- New internal RPC transport with heartbeat-based liveness detection
- Dead app connections fail fast with `AppBridgeDisconnectedError`
instead of ambiguous "device did not respond" after 90s

Should reduce the intermittent CI flakiness we've been seeing on legacy
iOS.
…ntModificationException (#286)

`onChanged` iterates `listeners.values` on a `Dispatchers.Default`
thread (the flow-collection coroutine), while
`addListener`/`removeListener` mutate the same map from the JS thread. A
plain `LinkedHashMap` fails fast with `ConcurrentModificationException`
when this overlap occurs.

`ConcurrentHashMap` iterates safely with concurrent mutations — no CME,
no locks needed at call sites.
…oid) (#285)

## Problem
On the experimental backend, `play()`/`pause()`/`reset()` were no-ops —
animations couldn't be paused/resumed and `autoPlay={false}` was
ignored.
- **iOS**: only mutated a private `isPaused` flag never forwarded to
`RiveUIView`.
- **Android**: empty bodies; the render loop advanced unconditionally
and `autoPlay` was never consumed.

Additionally, `useRiveTrigger` never fired on Android experimental —
three separate issues:
1. `awaitViewReady()` resolved at end of `configure()`, before the
TextureView surface existed. Triggers fired before the surface was up
were lost.
2. `getTriggerFlow` has `replay=0` (no initial value on subscription);
`drop=1` was silently eating the first real trigger.
3. The listener coroutine was launched with `DEFAULT` start, so it was
merely queued on `Dispatchers.Default` and could miss a trigger fired
before it reached `collect()`.

Also, `dataBind={{ byName }}` on Android always bound from
`vmNames.first()` (first VM in file) instead of the artboard's default
ViewModel.

## Fix
- **iOS** (`ios/new/RiveReactNativeView.swift`): forward `isPaused` to
`RiveUIView` in play/pause/playIfNeeded, pass it into
`RiveUIView(rive:isPaused:)`, set it from `autoPlay`.
- **Android** (`android/src/new/.../RiveReactNativeView.kt`): gate
`advanceStateMachine` on a `paused` flag toggled by play/pause, honor
`autoPlay`. Signal `awaitViewReady()` on first rendered frame (not end
of configure).
- **Android triggers**: use `drop=0` for trigger flow (no initial value
to skip); launch listener coroutine with `CoroutineStart.UNDISPATCHED`.
- **Android byName**: use `ViewModelSource.DefaultForArtboard(art)`
instead of `Named(vmNames.first())`, matching iOS and the Auto path.
- **reset()**: deprecated. Neither experimental backend has a reset
primitive — marked `@deprecated` in the spec, experimental impl logs an
error and does nothing.

## Verification
- `autoplay.harness.tsx`: pause freezes `ypos`, play resumes it,
`autoPlay={false}` holds. Green on both platforms.
- `use-rive-trigger.harness.tsx`: both stable and unstable-callback
tests now pass on Android.
- `viewmodel-instance-lookup.harness.tsx`: byName regression test
verifies `artboard2 + byName("vmi1") → _id="vm2.vmi1.id"` (not vm1).
- CI: legacy iOS `pod install` step clears `Podfile.lock` to avoid
external `fast_float` checksum rejection.
`asyncExecuteOnUiThread` posts to the UI thread and returns immediately,
so the Promise resolves before the work runs. If the action throws, it
crashes on the UI thread instead of rejecting the Promise.

`play()`/`pause()`/`reset()` on the experimental backend are just flag
toggles or a deprecation log — no UI thread requirement, no throw.
Replace with `Promise.async { view.play() }` etc.
Property accessors (`numberProperty`, `stringProperty`, etc.) were using
`runBlocking { flow.first() }` as an existence probe before returning
the wrapper. This blocks the calling thread and can deadlock if called
from the main thread (e.g. via a worklet).

The iOS experimental backend already does the right thing — it just
creates and returns the wrapper directly. This PR aligns Android
experimental to the same behaviour: return a wrapper always, fail lazily
when `getValueAsync()` is called on an invalid path.

Also removes the `propertyNames` lazy val (used for `hasProperty`) which
had its own one-time `runBlocking` call.

Test: updated guard for the "non-existent returns undefined" test (skips
on all experimental backends), added new test asserting
`getValueAsync()` rejects for invalid paths on experimental.
Removes unreachable and never-populated code from the experimental iOS
backend.

- Drop `eventListeners`, `addEventListener`, `removeEventListeners` from
`RiveReactNativeView` — the Hybrid layer throws before these are ever
called
- Drop `listenerTasks` from `HybridViewModelListProperty` and
`HybridViewModelImageProperty` — the dict is declared and torn down but
never populated
…iew reuse, bind-before-ready (#289)

Addresses several correctness issues from the experimental runtime
review.

- **Android**: cancel auto-bind `CoroutineScope` on dispose; adds
`disposed` guard inside the Main-thread hop to prevent post-teardown
binding against a stale/reconfigured handle
- **iOS**: reuse existing `RiveUIView` on reconfigure by assigning
`riveUIView.rive = rive` instead of tearing down and recreating the view
— eliminates orphaned MTKView draw calls on file switch
- **iOS**: queue `bindViewModelInstance` calls that arrive before
`configTask` completes, then apply them once `riveInstance` is set —
previously the bind was silently dropped

Includes harness tests for the iOS fixes:
- `reconfigure.harness.tsx` — verifies animation still plays after
switching file prop and back
- `bind-before-ready.harness.tsx` — reproduces and verifies the
bind-before-ready race (failed before fix, passes after)
Stacked on #289.

Adds `setValueAsync(value)` to Number, String, Boolean, Color, and Enum
property types across all 4 backends (iOS experimental, iOS legacy,
Android experimental, Android legacy).

On iOS experimental, this is a proper async write — the promise resolves
only after the SDK `setValue` call completes on the main actor, giving
ordering guarantees and error propagation. On legacy and Android,
`set()` is already synchronous so `setValueAsync` is a thin wrapper that
makes the API consistent.

Includes harness tests covering all 5 property types.
…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
…defined

Fabric's prop diff sends null (not undefined) when a prop is removed, but
Nitro's JSIConverter<std::optional<T>> only maps undefined to nullopt, so
clearing any optional RiveView prop (artboardName, stateMachineName,
autoPlay, alignment, fit, layoutScaleFactor, dataBind) threw e.g.
"Exception in HostFunction: RiveView.artboardName: Value is null,
expected a String" during commit. Affects both backends and both
platforms since the generated prop parser is shared.

Post-process the nitrogen output to treat null like undefined for
optional props until mrousavy/nitro#1184 is
fixed upstream. Adds an Optional Prop Clear reproducer page that sets
and clears each optional prop (fails 7/7 unfixed, passes fixed on both
Android backends).
mfazekas added a commit that referenced this pull request Jul 17, 2026
…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%).
Base automatically changed from feat/rive-ios-experimental to main July 20, 2026 13:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant