Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion android/src/main/java/com/rive/RiveReactNativeView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
if (reload) {
val hasDataBinding = when (config.bindData) {
is BindData.None -> false
is BindData.Auto -> config.riveFile.viewModelCount > 0
// Don't use SDK-level autoBind for Auto mode — it throws if the artboard
// has no default ViewModel. bindToStateMachine handles this gracefully instead.
is BindData.Auto -> false
is BindData.Instance, is BindData.ByName -> true
}
riveAnimationView?.setRiveFile(
Expand Down
28 changes: 28 additions & 0 deletions example/__tests__/hooks.harness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,34 @@ function expectDefined<T>(value: T): asserts value is NonNullable<T> {
}

describe('useRiveNumber Hook', () => {
it('starts undefined then receives value via listener', async () => {
const file = await RiveFileFactory.fromSource(QUICK_START, undefined);
const vm = file.defaultArtboardViewModel();
expectDefined(vm);
const instance = vm.createDefaultInstance();
expectDefined(instance);

const context = createUseRiveNumberContext();

// Value must start undefined — not synchronously read from property.value
expect(context.value).toBeUndefined();

await render(
<UseRiveNumberTestComponent instance={instance} context={context} />
);

// After listener fires, value should be a number
await waitFor(
() => {
expect(context.error).toBeNull();
expect(typeof context.value).toBe('number');
},
{ timeout: 5000 }
);

cleanup();
});

it('returns value from number property', async () => {
const file = await RiveFileFactory.fromSource(QUICK_START, undefined);
const vm = file.defaultArtboardViewModel();
Expand Down
Binary file added example/assets/rive/nodefaultbouncing.riv
Binary file not shown.
68 changes: 68 additions & 0 deletions example/src/reproducers/Issue189.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Reproducer for https://github.com/rive-app/rive-nitro-react-native/issues/189
*
* [Android] Rive files that have ViewModels but no default ViewModel for the
* artboard freeze when dataBind is not explicitly set (defaults to Auto).
*
* Root cause: in Auto mode Android checks viewModelCount > 0 and passes
* autoBind=true to setRiveFile. The Rive SDK then throws
* "No default ViewModel found for artboard" when the artboard has no default
* ViewModel assigned, which freezes the animation.
*
* Fix: don't use SDK-level autoBind for Auto mode. Let bindToStateMachine
* handle it — it already catches ViewModelException gracefully.
*
* Marketplace: https://rive.app/community/files/27026-50856-no-default-vm-for-artboard/
*
* Expected: bouncing animation plays on both platforms
* Actual (Android, unfixed): animation freezes, ViewModelInstanceNotFound error
*/

import { View, StyleSheet, Text } from 'react-native';
import { RiveView, useRiveFile } from '@rive-app/react-native';
import { type Metadata } from '../shared/metadata';

export default function Issue189Page() {
const { riveFile, error } = useRiveFile(
require('../../assets/rive/nodefaultbouncing.riv')
);

return (
<View style={styles.container}>
{error != null && (
<Text style={styles.errorText}>Error: {String(error)}</Text>
)}
{riveFile && (
<RiveView
file={riveFile}
autoPlay={true}
// No dataBind prop — defaults to Auto. On Android (unfixed) this
// triggers "No default ViewModel found for artboard" and freezes.
style={styles.rive}
/>
)}
</View>
);
}

Issue189Page.metadata = {
name: 'Issue #189',
description:
'[Android] Animation with ViewModels but no artboard default freezes in Auto dataBind mode',
} satisfies Metadata;

const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
errorText: {
color: 'red',
textAlign: 'center',
padding: 8,
},
rive: {
flex: 1,
width: '100%',
},
});
9 changes: 8 additions & 1 deletion src/hooks/__tests__/useRiveProperty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ describe('useRiveProperty', () => {
},
addListener: jest.fn((callback: (value: string) => void) => {
listener = callback;
// Emit the current value immediately on subscribe, matching native behaviour:
// iOS legacy emits synchronously; experimental backend emits via valueStream.
callback(currentValue);
return () => {
listener = null;
};
Expand All @@ -36,7 +39,9 @@ describe('useRiveProperty', () => {
} as unknown as ViewModelInstance;
};

it('should return initial value from property on first render', () => {
it('should return initial value delivered via listener (not from a sync read)', () => {
// Hooks always start undefined; the listener emits the current value immediately
// on subscribe (synchronously for legacy, via stream for experimental).
const mockProperty = createMockProperty('Tea');
const mockInstance = createMockViewModelInstance({
'favDrink/type': mockProperty,
Expand All @@ -48,6 +53,8 @@ describe('useRiveProperty', () => {
})
);

// The mock's addListener emits 'Tea' synchronously — React batches it with the
// effect, so the value is available after renderHook (which wraps in act()).
const [value] = result.current;
expect(value).toBe('Tea');
});
Expand Down
34 changes: 17 additions & 17 deletions src/hooks/useRiveProperty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ export function useRiveProperty<P extends ViewModelProperty, T>(
Error | null,
P | undefined,
] {
// Get the property first so we can read its initial value
const property = useMemo(() => {
if (!viewModelInstance) return;
return options.getProperty(
Expand All @@ -43,17 +42,12 @@ export function useRiveProperty<P extends ViewModelProperty, T>(
) as unknown as ObservableViewModelProperty<T>;
}, [options, viewModelInstance, path]);

// Initialize state with property's current value (if available)
const [value, setValue] = useState<T | undefined>(() => property?.value);
// Always start undefined — the listener delivers the current value as its first emission.
// (iOS experimental: via valueStream; iOS/Android legacy: emitted synchronously on subscribe)
// This ensures consumers handle the loading state correctly on all backends.
const [value, setValue] = useState<T | undefined>(undefined);
const [error, setError] = useState<Error | null>(null);

// Sync value when property reference changes (path or instance changed)
useEffect(() => {
if (property) {
setValue(property.value);
}
}, [property]);

// Clear error when path or instance changes
useEffect(() => {
setError(null);
Expand All @@ -72,8 +66,14 @@ export function useRiveProperty<P extends ViewModelProperty, T>(
useEffect(() => {
if (!property) return;

// If an override callback is provided, use it.
// Otherwise, use the default callback.
// Deliver the current value immediately so the hook transitions from
// undefined → value without waiting for a property change.
// (Legacy addListener does NOT emit on subscribe — only on changes.
// Experimental valueStream emits the current value as its first element.)
if (!options.onPropertyEventOverride) {
setValue(property.value);
}

const removeListener = options.onPropertyEventOverride
? property.addListener(options.onPropertyEventOverride)
: property.addListener((newValue) => {
Expand All @@ -86,22 +86,22 @@ export function useRiveProperty<P extends ViewModelProperty, T>(
};
}, [options, property]);

// Set the value of the property (no-op if property isn't available yet)
// Set the value of the property (no-op if property isn't available yet).
// Uses tracked `value` from state for updater functions — avoids a synchronous
// property.value read and is consistent with how React state works.
const setPropertyValue = useCallback(
(valueOrUpdater: T | ((prevValue: T | undefined) => T)) => {
if (!property) {
return;
} else {
const newValue =
typeof valueOrUpdater === 'function'
? (valueOrUpdater as (prevValue: T | undefined) => T)(
property.value
)
? (valueOrUpdater as (prevValue: T | undefined) => T)(value)
: valueOrUpdater;
property.value = newValue;
}
},
[property]
[property, value]
);

return [value, setPropertyValue, error, property as unknown as P];
Expand Down
Loading