diff --git a/example/__tests__/reconfigure.harness.tsx b/example/__tests__/reconfigure.harness.tsx
index 131dfbf8..5759d24d 100644
--- a/example/__tests__/reconfigure.harness.tsx
+++ b/example/__tests__/reconfigure.harness.tsx
@@ -14,6 +14,7 @@ import {
Fit,
type RiveFile,
type RiveViewRef,
+ type ViewModelNumberProperty,
} from '@rive-app/react-native';
const BOUNCING_BALL = require('../assets/rive/bouncing_ball.riv');
@@ -74,11 +75,17 @@ describe('RiveView reconfigure (file switch)', () => {
await render();
await waitFor(() => expect(context.ref).not.toBeNull(), { timeout: 5000 });
- // Confirm bouncing_ball is animating via its ypos ViewModel property
- const vmi1 = context.ref!.getViewModelInstance();
- expect(vmi1).not.toBeNull();
- const ypos1 = vmi1!.numberProperty('ypos');
- expect(ypos1).toBeDefined();
+ // The ViewModel instance and its properties resolve asynchronously a short
+ // time after the view ref is assigned, so poll until the property is
+ // available rather than reading it the instant the ref exists.
+ let ypos1: ViewModelNumberProperty | undefined;
+ await waitFor(
+ () => {
+ ypos1 = context.ref!.getViewModelInstance()?.numberProperty('ypos');
+ expect(ypos1).toBeDefined();
+ },
+ { timeout: 5000 }
+ );
const valueBefore = ypos1!.value;
await delay(500);
expect(ypos1!.value).not.toBe(valueBefore);
@@ -93,11 +100,16 @@ describe('RiveView reconfigure (file switch)', () => {
await delay(600);
expect(context.error).toBeNull();
- // Animation should still be running on the reconfigured view
- const vmi2 = context.ref!.getViewModelInstance();
- expect(vmi2).not.toBeNull();
- const ypos2 = vmi2!.numberProperty('ypos');
- expect(ypos2).toBeDefined();
+ // Animation should still be running on the reconfigured view. The
+ // reconfigured instance also resolves asynchronously, so poll for it too.
+ let ypos2: ViewModelNumberProperty | undefined;
+ await waitFor(
+ () => {
+ ypos2 = context.ref!.getViewModelInstance()?.numberProperty('ypos');
+ expect(ypos2).toBeDefined();
+ },
+ { timeout: 5000 }
+ );
const valueAfterSwitch = ypos2!.value;
await delay(500);
expect(ypos2!.value).not.toBe(valueAfterSwitch);
diff --git a/example/src/demos/DataBindingArtboardsExample.tsx b/example/src/demos/DataBindingArtboardsExample.tsx
index e2748bed..49cc9f36 100644
--- a/example/src/demos/DataBindingArtboardsExample.tsx
+++ b/example/src/demos/DataBindingArtboardsExample.tsx
@@ -10,7 +10,7 @@ import {
Fit,
RiveView,
useRiveFile,
- useViewModelInstance,
+ useViewModelInstanceAsync,
type RiveFile,
type BindableArtboard,
} from '@rive-app/react-native';
@@ -78,7 +78,7 @@ function ArtboardSwapper({
mainFile: RiveFile;
assetsFile: RiveFile;
}) {
- const { instance, error } = useViewModelInstance(mainFile);
+ const { instance, error } = useViewModelInstanceAsync(mainFile);
const [currentArtboard, setCurrentArtboard] = useState('Dragon');
const initializedRef = useRef(false);
diff --git a/example/src/demos/QuickStart.tsx b/example/src/demos/QuickStart.tsx
index 4b658262..1b582847 100644
--- a/example/src/demos/QuickStart.tsx
+++ b/example/src/demos/QuickStart.tsx
@@ -13,7 +13,7 @@ import {
useRiveFile,
useRiveNumber,
useRiveTrigger,
- useViewModelInstance,
+ useViewModelInstanceAsync,
Fit,
} from '@rive-app/react-native';
import type { Metadata } from '../shared/metadata';
@@ -23,7 +23,7 @@ export default function QuickStart() {
require('../../assets/rive/quick_start.riv')
);
const { riveViewRef, setHybridRef } = useRive();
- const { instance: viewModelInstance } = useViewModelInstance(riveFile, {
+ const { instance: viewModelInstance } = useViewModelInstanceAsync(riveFile, {
onInit: (vmi) => vmi.numberProperty('health')!.set(9),
});
diff --git a/example/src/exercisers/FontFallbackExample.tsx b/example/src/exercisers/FontFallbackExample.tsx
index 931593d6..d2eb5a80 100644
--- a/example/src/exercisers/FontFallbackExample.tsx
+++ b/example/src/exercisers/FontFallbackExample.tsx
@@ -15,7 +15,7 @@ import {
useRive,
useRiveFile,
useRiveString,
- useViewModelInstance,
+ useViewModelInstanceAsync,
type FontSource,
type FallbackFont,
} from '@rive-app/react-native';
@@ -258,7 +258,7 @@ function MountedView({ text }: { text: string }) {
// https://rive.app/marketplace/26480-49641-simple-test-text-property/
require('../../assets/rive/font_fallback.riv')
);
- const { instance } = useViewModelInstance(riveFile);
+ const { instance } = useViewModelInstanceAsync(riveFile);
const { setValue: setRiveText, error: textError } = useRiveString(
TEXT_PROPERTY,
diff --git a/example/src/exercisers/MenuListExample.tsx b/example/src/exercisers/MenuListExample.tsx
index d81ce97c..2f437ad1 100644
--- a/example/src/exercisers/MenuListExample.tsx
+++ b/example/src/exercisers/MenuListExample.tsx
@@ -16,7 +16,7 @@ import {
type RiveFile,
useRiveFile,
useRiveList,
- useViewModelInstance,
+ useViewModelInstanceAsync,
} from '@rive-app/react-native';
import { type Metadata } from '../shared/metadata';
@@ -41,7 +41,7 @@ export default function MenuListExample() {
}
function MenuList({ file }: { file: RiveFile }) {
- const { instance, error } = useViewModelInstance(file);
+ const { instance, error } = useViewModelInstanceAsync(file);
if (error) {
console.error(error.message);
diff --git a/example/src/exercisers/NestedViewModelExample.tsx b/example/src/exercisers/NestedViewModelExample.tsx
index 0f0e39d1..60cc3cba 100644
--- a/example/src/exercisers/NestedViewModelExample.tsx
+++ b/example/src/exercisers/NestedViewModelExample.tsx
@@ -13,7 +13,7 @@ import {
RiveView,
useRiveFile,
useRiveString,
- useViewModelInstance,
+ useViewModelInstanceAsync,
type ViewModelInstance,
type RiveFile,
type RiveViewRef,
@@ -41,7 +41,7 @@ export default function NestedViewModelExample() {
}
function WithViewModelSetup({ file }: { file: RiveFile }) {
- const { instance, error } = useViewModelInstance(file);
+ const { instance, error } = useViewModelInstanceAsync(file);
if (error) {
console.error(error.message);
diff --git a/example/src/exercisers/RiveDataBindingExample.tsx b/example/src/exercisers/RiveDataBindingExample.tsx
index 825c81f7..7b5a9aa7 100644
--- a/example/src/exercisers/RiveDataBindingExample.tsx
+++ b/example/src/exercisers/RiveDataBindingExample.tsx
@@ -4,7 +4,7 @@ import {
Fit,
RiveView,
useRiveNumber,
- useViewModelInstance,
+ useViewModelInstanceAsync,
type ViewModelInstance,
type RiveFile,
useRiveString,
@@ -37,7 +37,7 @@ export default function WithRiveFile() {
}
function WithViewModelSetup({ file }: { file: RiveFile }) {
- const { instance, error } = useViewModelInstance(file);
+ const { instance, error } = useViewModelInstanceAsync(file);
if (error) {
console.error(error.message);
diff --git a/example/src/reproducers/Issue297ThreadRace.tsx b/example/src/reproducers/Issue297ThreadRace.tsx
index 10af8f40..1699a124 100644
--- a/example/src/reproducers/Issue297ThreadRace.tsx
+++ b/example/src/reproducers/Issue297ThreadRace.tsx
@@ -11,7 +11,7 @@ import {
Fit,
RiveView,
useRiveFile,
- useViewModelInstance,
+ useViewModelInstanceAsync,
type ViewModelInstance,
type RiveFile,
} from '@rive-app/react-native';
@@ -130,7 +130,7 @@ function StressRunner({
}
function WithViewModelSetup({ file }: { file: RiveFile }) {
- const { instance, error } = useViewModelInstance(file);
+ const { instance, error } = useViewModelInstanceAsync(file);
if (error) {
return {error.message};
diff --git a/package.json b/package.json
index 93d4b37d..0ef41c31 100644
--- a/package.json
+++ b/package.json
@@ -66,7 +66,7 @@
"homepage": "https://github.com/rive-app/rive-nitro-react-native#readme",
"runtimeVersions": {
"ios": "6.20.4",
- "android": "11.4.1"
+ "android": "11.6.1"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/"
diff --git a/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx b/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx
new file mode 100644
index 00000000..b92476f2
--- /dev/null
+++ b/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx
@@ -0,0 +1,382 @@
+import React from 'react';
+import {
+ renderHook,
+ render,
+ waitFor,
+ act,
+} from '@testing-library/react-native';
+import { useViewModelInstanceAsync } from '../useViewModelInstanceAsync';
+import type { RiveFile } from '../../specs/RiveFile.nitro';
+import type { ViewModel, ViewModelInstance } from '../../specs/ViewModel.nitro';
+import type { ArtboardBy } from '../../specs/ArtboardBy';
+
+function createMockViewModelInstance(name = 'TestInstance'): ViewModelInstance {
+ return {
+ instanceName: name,
+ dispose: jest.fn(),
+ numberProperty: jest.fn(),
+ stringProperty: jest.fn(),
+ booleanProperty: jest.fn(),
+ colorProperty: jest.fn(),
+ enumProperty: jest.fn(),
+ triggerProperty: jest.fn(),
+ imageProperty: jest.fn(),
+ listProperty: jest.fn(),
+ artboardProperty: jest.fn(),
+ viewModelAsync: jest.fn(),
+ replaceViewModel: jest.fn(),
+ } as any;
+}
+
+function createMockViewModel(options?: {
+ defaultInstance?: ViewModelInstance;
+ namedInstances?: Record;
+ blankInstance?: ViewModelInstance;
+}): ViewModel {
+ return {
+ modelName: 'TestViewModel',
+ dispose: jest.fn(),
+ createInstanceByNameAsync: jest.fn(
+ async (name: string) => options?.namedInstances?.[name]
+ ),
+ createDefaultInstanceAsync: jest.fn(async () => options?.defaultInstance),
+ createBlankInstanceAsync: jest.fn(async () => options?.blankInstance),
+ getPropertiesAsync: jest.fn(),
+ getPropertyCountAsync: jest.fn(),
+ getInstanceCountAsync: jest.fn(),
+ } as any;
+}
+
+function createMockRiveFile(options?: {
+ defaultViewModel?: ViewModel;
+ artboardViewModels?: Record;
+ namedViewModels?: Record;
+}): RiveFile {
+ return {
+ dispose: jest.fn(),
+ getBindableArtboard: jest.fn(),
+ viewModelByNameAsync: jest.fn(
+ async (name: string) => options?.namedViewModels?.[name]
+ ),
+ defaultArtboardViewModelAsync: jest.fn(async (artboardBy?: ArtboardBy) => {
+ if (artboardBy?.name && options?.artboardViewModels) {
+ return options.artboardViewModels[artboardBy.name];
+ }
+ return options?.defaultViewModel;
+ }),
+ } as any;
+}
+
+describe('useViewModelInstanceAsync - RiveFile source', () => {
+ it('is loading on first render, then resolves the default instance', async () => {
+ const defaultInstance = createMockViewModelInstance();
+ const defaultViewModel = createMockViewModel({ defaultInstance });
+ const mockRiveFile = createMockRiveFile({ defaultViewModel });
+
+ const { result } = renderHook(() =>
+ useViewModelInstanceAsync(mockRiveFile)
+ );
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.instance).toBeUndefined();
+ expect(result.current.error).toBeNull();
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.instance).toBe(defaultInstance);
+ expect(result.current.error).toBeNull();
+ expect(defaultViewModel.createDefaultInstanceAsync).toHaveBeenCalled();
+ });
+
+ it('resolves a named instance via createInstanceByNameAsync', async () => {
+ const personInstance = createMockViewModelInstance('Person');
+ const defaultViewModel = createMockViewModel({
+ namedInstances: { PersonInstance: personInstance },
+ });
+ const mockRiveFile = createMockRiveFile({ defaultViewModel });
+
+ const { result } = renderHook(() =>
+ useViewModelInstanceAsync(mockRiveFile, {
+ instanceName: 'PersonInstance',
+ })
+ );
+
+ await waitFor(() => expect(result.current.instance).toBe(personInstance));
+ expect(defaultViewModel.createInstanceByNameAsync).toHaveBeenCalledWith(
+ 'PersonInstance'
+ );
+ expect(result.current.error).toBeNull();
+ });
+
+ it('resolves the ViewModel for a specific artboard', async () => {
+ const mainInstance = createMockViewModelInstance('Main');
+ const mainArtboardViewModel = createMockViewModel({
+ defaultInstance: mainInstance,
+ });
+ const mockRiveFile = createMockRiveFile({
+ artboardViewModels: { MainArtboard: mainArtboardViewModel },
+ });
+
+ const { result } = renderHook(() =>
+ useViewModelInstanceAsync(mockRiveFile, { artboardName: 'MainArtboard' })
+ );
+
+ await waitFor(() => expect(result.current.instance).toBe(mainInstance));
+ expect(mockRiveFile.defaultArtboardViewModelAsync).toHaveBeenCalledWith({
+ type: 'name',
+ name: 'MainArtboard',
+ });
+ });
+
+ it('resolves a ViewModel by name', async () => {
+ const settingsInstance = createMockViewModelInstance('Settings');
+ const settingsViewModel = createMockViewModel({
+ defaultInstance: settingsInstance,
+ });
+ const mockRiveFile = createMockRiveFile({
+ namedViewModels: { Settings: settingsViewModel },
+ });
+
+ const { result } = renderHook(() =>
+ useViewModelInstanceAsync(mockRiveFile, { viewModelName: 'Settings' })
+ );
+
+ await waitFor(() => expect(result.current.instance).toBe(settingsInstance));
+ expect(mockRiveFile.viewModelByNameAsync).toHaveBeenCalledWith('Settings');
+ expect(mockRiveFile.defaultArtboardViewModelAsync).not.toHaveBeenCalled();
+ });
+
+ it('sets an error when the instance name is not found (not required)', async () => {
+ const defaultViewModel = createMockViewModel({ namedInstances: {} });
+ const mockRiveFile = createMockRiveFile({ defaultViewModel });
+
+ const { result } = renderHook(() =>
+ useViewModelInstanceAsync(mockRiveFile, { instanceName: 'NonExistent' })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.instance).toBeNull();
+ expect(result.current.error).toBeInstanceOf(Error);
+ expect(result.current.error?.message).toContain('NonExistent');
+ });
+
+ it('resolves null with no error when the artboard has no ViewModel', async () => {
+ const mockRiveFile = createMockRiveFile({});
+
+ const { result } = renderHook(() =>
+ useViewModelInstanceAsync(mockRiveFile)
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.instance).toBeNull();
+ expect(result.current.error).toBeNull();
+ });
+});
+
+describe('useViewModelInstanceAsync - ViewModel source', () => {
+ it('uses createDefaultInstanceAsync by default', async () => {
+ const defaultInstance = createMockViewModelInstance();
+ const mockViewModel = createMockViewModel({ defaultInstance });
+
+ const { result } = renderHook(() =>
+ useViewModelInstanceAsync(mockViewModel)
+ );
+
+ await waitFor(() => expect(result.current.instance).toBe(defaultInstance));
+ expect(mockViewModel.createDefaultInstanceAsync).toHaveBeenCalled();
+ });
+
+ it('uses createBlankInstanceAsync when useNew is true', async () => {
+ const blankInstance = createMockViewModelInstance('Blank');
+ const mockViewModel = createMockViewModel({ blankInstance });
+
+ const { result } = renderHook(() =>
+ useViewModelInstanceAsync(mockViewModel, { useNew: true })
+ );
+
+ await waitFor(() => expect(result.current.instance).toBe(blankInstance));
+ expect(mockViewModel.createBlankInstanceAsync).toHaveBeenCalled();
+ expect(mockViewModel.createDefaultInstanceAsync).not.toHaveBeenCalled();
+ });
+
+ it('uses createInstanceByNameAsync when name is provided', async () => {
+ const namedInstance = createMockViewModelInstance('Gordon');
+ const mockViewModel = createMockViewModel({
+ namedInstances: { Gordon: namedInstance },
+ });
+
+ const { result } = renderHook(() =>
+ useViewModelInstanceAsync(mockViewModel, { name: 'Gordon' })
+ );
+
+ await waitFor(() => expect(result.current.instance).toBe(namedInstance));
+ expect(mockViewModel.createInstanceByNameAsync).toHaveBeenCalledWith(
+ 'Gordon'
+ );
+ });
+});
+
+describe('useViewModelInstanceAsync - null source', () => {
+ it('stays in the loading state when the source is null', async () => {
+ const { result } = renderHook(() => useViewModelInstanceAsync(null));
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.instance).toBeUndefined();
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.instance).toBeUndefined();
+ });
+});
+
+describe('useViewModelInstanceAsync - onInit', () => {
+ it('calls onInit with the resolved instance', async () => {
+ const defaultInstance = createMockViewModelInstance();
+ const defaultViewModel = createMockViewModel({ defaultInstance });
+ const mockRiveFile = createMockRiveFile({ defaultViewModel });
+ const onInit = jest.fn();
+
+ const { result } = renderHook(() =>
+ useViewModelInstanceAsync(mockRiveFile, { onInit })
+ );
+
+ await waitFor(() => expect(result.current.instance).toBe(defaultInstance));
+ expect(onInit).toHaveBeenCalledWith(defaultInstance);
+ expect(onInit).toHaveBeenCalledTimes(1);
+ });
+
+ it('surfaces an onInit throw as the error result', async () => {
+ const defaultInstance = createMockViewModelInstance();
+ const defaultViewModel = createMockViewModel({ defaultInstance });
+ const mockRiveFile = createMockRiveFile({ defaultViewModel });
+
+ const { result } = renderHook(() =>
+ useViewModelInstanceAsync(mockRiveFile, {
+ onInit: () => {
+ throw new Error('init boom');
+ },
+ })
+ );
+
+ await waitFor(() => expect(result.current.error).toBeInstanceOf(Error));
+ expect(result.current.error?.message).toBe('init boom');
+ expect(result.current.instance).toBeNull();
+ expect(defaultInstance.dispose).toHaveBeenCalled();
+ });
+});
+
+describe('useViewModelInstanceAsync - disposal', () => {
+ it('disposes the instance on unmount', async () => {
+ const defaultInstance = createMockViewModelInstance();
+ const defaultViewModel = createMockViewModel({ defaultInstance });
+ const mockRiveFile = createMockRiveFile({ defaultViewModel });
+
+ const { result, unmount } = renderHook(() =>
+ useViewModelInstanceAsync(mockRiveFile)
+ );
+
+ await waitFor(() => expect(result.current.instance).toBe(defaultInstance));
+ unmount();
+ expect(defaultInstance.dispose).toHaveBeenCalled();
+ });
+
+ it('disposes a late-resolving instance when unmounted before resolution', async () => {
+ const defaultInstance = createMockViewModelInstance();
+ const defaultViewModel = createMockViewModel();
+ let resolveCreate: (v: ViewModelInstance) => void = () => {};
+ (defaultViewModel.createDefaultInstanceAsync as jest.Mock).mockReturnValue(
+ new Promise((resolve) => {
+ resolveCreate = resolve;
+ })
+ );
+ const mockRiveFile = createMockRiveFile({ defaultViewModel });
+
+ const { unmount } = renderHook(() =>
+ useViewModelInstanceAsync(mockRiveFile)
+ );
+
+ unmount();
+
+ await act(async () => {
+ resolveCreate(defaultInstance);
+ await Promise.resolve();
+ });
+
+ expect(defaultInstance.dispose).toHaveBeenCalled();
+ });
+
+ it('disposes the previous instance when the source changes', async () => {
+ const instanceA = createMockViewModelInstance('A');
+ const fileA = createMockRiveFile({
+ defaultViewModel: createMockViewModel({ defaultInstance: instanceA }),
+ });
+ const instanceB = createMockViewModelInstance('B');
+ const fileB = createMockRiveFile({
+ defaultViewModel: createMockViewModel({ defaultInstance: instanceB }),
+ });
+
+ const { result, rerender } = renderHook(
+ ({ file }: { file: RiveFile }) => useViewModelInstanceAsync(file),
+ { initialProps: { file: fileA } }
+ );
+
+ await waitFor(() => expect(result.current.instance).toBe(instanceA));
+
+ await act(async () => {
+ rerender({ file: fileB });
+ await Promise.resolve();
+ });
+
+ await waitFor(() => expect(result.current.instance).toBe(instanceB));
+ expect(instanceA.dispose).toHaveBeenCalled();
+ });
+});
+
+describe('useViewModelInstanceAsync - required', () => {
+ class ErrorBoundary extends React.Component<
+ { onError: (e: Error) => void; children?: React.ReactNode },
+ { hasError: boolean }
+ > {
+ state = { hasError: false };
+ static getDerivedStateFromError() {
+ return { hasError: true };
+ }
+ componentDidCatch(error: Error) {
+ this.props.onError(error);
+ }
+ render() {
+ return this.state.hasError ? null : this.props.children;
+ }
+ }
+
+ function Probe({ source }: { source: RiveFile }) {
+ useViewModelInstanceAsync(source, {
+ viewModelName: 'NonExistent',
+ required: true,
+ });
+ return null;
+ }
+
+ it('throws (caught by an Error Boundary) once it resolves to null', async () => {
+ const consoleError = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+ const mockRiveFile = createMockRiveFile({ namedViewModels: {} });
+ const onError = jest.fn();
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => expect(onError).toHaveBeenCalled());
+ expect((onError.mock.calls[0][0] as Error).message).toContain(
+ 'NonExistent'
+ );
+ consoleError.mockRestore();
+ });
+});
diff --git a/src/hooks/useViewModelInstance.ts b/src/hooks/useViewModelInstance.ts
index 3dc655a6..6d9257d8 100644
--- a/src/hooks/useViewModelInstance.ts
+++ b/src/hooks/useViewModelInstance.ts
@@ -201,6 +201,10 @@ export type UseViewModelInstanceResult =
/**
* Hook for getting a ViewModelInstance from a RiveFile, ViewModel, or RiveViewRef.
*
+ * @deprecated Use {@link useViewModelInstanceAsync} instead. This hook creates the
+ * instance synchronously via deprecated runtime APIs that access the Rive runtime on
+ * the JS thread; the async variant uses the non-deprecated `*Async` APIs.
+ *
* @param source - The RiveFile, ViewModel, or RiveViewRef to get an instance from
* @param params - Configuration for which instance to retrieve
* @returns An object with `instance` and `error` (discriminated union)
diff --git a/src/hooks/useViewModelInstanceAsync.ts b/src/hooks/useViewModelInstanceAsync.ts
new file mode 100644
index 00000000..b5219037
--- /dev/null
+++ b/src/hooks/useViewModelInstanceAsync.ts
@@ -0,0 +1,341 @@
+import { useEffect, useRef, useState } from 'react';
+import type { ViewModel, ViewModelInstance } from '../specs/ViewModel.nitro';
+import type { RiveFile } from '../specs/RiveFile.nitro';
+import type { RiveViewRef } from '../index';
+import { callDispose } from '../core/callDispose';
+import { ArtboardByName } from '../specs/ArtboardBy';
+import type {
+ UseViewModelInstanceFileParams,
+ UseViewModelInstanceViewModelParams,
+ UseViewModelInstanceRefParams,
+} from './useViewModelInstance';
+
+type ViewModelSource = ViewModel | RiveFile | RiveViewRef;
+
+function isRiveViewRef(
+ source: ViewModelSource | null | undefined
+): source is RiveViewRef {
+ return source != null && 'getViewModelInstance' in source;
+}
+
+function isRiveFile(
+ source: ViewModelSource | null | undefined
+): source is RiveFile {
+ return source != null && 'defaultArtboardViewModelAsync' in source;
+}
+
+type CreateInstanceResult = {
+ instance: ViewModelInstance | null | undefined;
+ needsDispose: boolean;
+ error?: string;
+};
+
+async function createInstanceAsync(
+ source: ViewModelSource | null | undefined,
+ instanceName: string | undefined,
+ artboardName: string | undefined,
+ viewModelName: string | undefined,
+ useNew: boolean
+): Promise {
+ if (!source) {
+ return { instance: undefined, needsDispose: false };
+ }
+
+ if (isRiveViewRef(source)) {
+ const vmi = source.getViewModelInstance();
+ return { instance: vmi ?? null, needsDispose: false };
+ }
+
+ if (isRiveFile(source)) {
+ let viewModel: ViewModel | undefined;
+ if (viewModelName) {
+ viewModel = await source.viewModelByNameAsync(viewModelName);
+ if (!viewModel) {
+ return {
+ instance: null,
+ needsDispose: false,
+ error: `ViewModel '${viewModelName}' not found`,
+ };
+ }
+ } else {
+ viewModel = await source.defaultArtboardViewModelAsync(
+ artboardName ? ArtboardByName(artboardName) : undefined
+ );
+ if (!viewModel) {
+ if (artboardName) {
+ return {
+ instance: null,
+ needsDispose: false,
+ error: `Artboard '${artboardName}' not found or has no ViewModel`,
+ };
+ }
+ return { instance: null, needsDispose: false };
+ }
+ }
+ let vmi: ViewModelInstance | undefined;
+ if (instanceName) {
+ try {
+ vmi = await viewModel.createInstanceByNameAsync(instanceName);
+ } catch (e) {
+ console.warn(`createInstanceByNameAsync('${instanceName}') failed:`, e);
+ }
+ } else {
+ vmi = await viewModel.createDefaultInstanceAsync();
+ }
+ if (!vmi && instanceName) {
+ return {
+ instance: null,
+ needsDispose: false,
+ error: `ViewModel instance '${instanceName}' not found`,
+ };
+ }
+ return { instance: vmi ?? null, needsDispose: true };
+ }
+
+ // ViewModel source
+ let vmi: ViewModelInstance | undefined;
+ if (instanceName) {
+ try {
+ vmi = await source.createInstanceByNameAsync(instanceName);
+ } catch (e) {
+ console.warn(`createInstanceByNameAsync('${instanceName}') failed:`, e);
+ }
+ if (!vmi) {
+ return {
+ instance: null,
+ needsDispose: false,
+ error: `ViewModel instance '${instanceName}' not found`,
+ };
+ }
+ } else if (useNew) {
+ vmi = await source.createBlankInstanceAsync();
+ } else {
+ vmi = await source.createDefaultInstanceAsync();
+ }
+ return { instance: vmi ?? null, needsDispose: true };
+}
+
+export type UseViewModelInstanceAsyncResult =
+ | { instance: ViewModelInstance; isLoading: false; error: null }
+ | { instance: null; isLoading: false; error: Error }
+ | { instance: null; isLoading: false; error: null }
+ | { instance: undefined; isLoading: true; error: null };
+
+/**
+ * Result of {@link useViewModelInstanceAsync} when `required: true` is set.
+ * The `null` (error/absent) case is removed — instead the hook throws once the
+ * instance resolves to `null`, leaving only the ready and loading states.
+ */
+type UseViewModelInstanceAsyncRequiredResult =
+ | { instance: ViewModelInstance; isLoading: false; error: null }
+ | { instance: undefined; isLoading: true; error: null };
+
+const LOADING_RESULT: UseViewModelInstanceAsyncResult = {
+ instance: undefined,
+ isLoading: true,
+ error: null,
+};
+
+/**
+ * Async version of {@link useViewModelInstance}. Creates a ViewModelInstance
+ * using the non-deprecated `*Async` runtime APIs, resolving off the JS thread.
+ *
+ * Because creation is asynchronous, the instance is not available on the first
+ * render. Consumers should guard on the result:
+ *
+ * ```tsx
+ * const { instance, isLoading, error } = useViewModelInstanceAsync(riveFile);
+ * if (isLoading || !instance) return ;
+ * // ...
+ *
+ * ```
+ *
+ * @param source - The RiveFile, ViewModel, or RiveViewRef to get an instance from
+ * @param params - Configuration for which instance to retrieve
+ * @returns An object with `instance`, `isLoading`, and `error` (discriminated union)
+ *
+ * @example
+ * ```tsx
+ * // From RiveFile (get default instance)
+ * const { riveFile } = useRiveFile(require('./animation.riv'));
+ * const { instance, isLoading } = useViewModelInstanceAsync(riveFile);
+ * ```
+ *
+ * @example
+ * ```tsx
+ * // From RiveFile with specific instance name
+ * const { instance } = useViewModelInstanceAsync(riveFile, { instanceName: 'PersonInstance' });
+ * ```
+ *
+ * @example
+ * ```tsx
+ * // From RiveFile with specific ViewModel name
+ * const { instance } = useViewModelInstanceAsync(riveFile, { viewModelName: 'Settings' });
+ * ```
+ *
+ * @example
+ * ```tsx
+ * // Create a new blank instance from ViewModel
+ * const viewModel = await file.viewModelByNameAsync('TodoItem');
+ * const { instance } = useViewModelInstanceAsync(viewModel, { useNew: true });
+ * ```
+ *
+ * @example
+ * ```tsx
+ * // With required: true (throws once resolved to null, use with Error Boundary).
+ * // Note: instance is still `undefined` while loading — guard on isLoading.
+ * const { instance, isLoading } = useViewModelInstanceAsync(riveFile, { required: true });
+ * ```
+ *
+ * @example
+ * ```tsx
+ * // With onInit to set initial values before the instance is exposed or bound
+ * const { instance } = useViewModelInstanceAsync(riveFile, {
+ * onInit: (vmi) => {
+ * vmi.numberProperty('count')?.set(initialCount);
+ * }
+ * });
+ * ```
+ */
+// RiveFile overloads
+export function useViewModelInstanceAsync(
+ source: RiveFile,
+ params: UseViewModelInstanceFileParams & { required: true }
+): UseViewModelInstanceAsyncRequiredResult;
+export function useViewModelInstanceAsync(
+ source: RiveFile | null | undefined,
+ params?: UseViewModelInstanceFileParams
+): UseViewModelInstanceAsyncResult;
+
+// ViewModel overloads
+export function useViewModelInstanceAsync(
+ source: ViewModel,
+ params: UseViewModelInstanceViewModelParams & { required: true }
+): UseViewModelInstanceAsyncRequiredResult;
+export function useViewModelInstanceAsync(
+ source: ViewModel | null | undefined,
+ params?: UseViewModelInstanceViewModelParams
+): UseViewModelInstanceAsyncResult;
+
+// RiveViewRef overloads
+export function useViewModelInstanceAsync(
+ source: RiveViewRef,
+ params: UseViewModelInstanceRefParams & { required: true }
+): UseViewModelInstanceAsyncRequiredResult;
+export function useViewModelInstanceAsync(
+ source: RiveViewRef | null | undefined,
+ params?: UseViewModelInstanceRefParams
+): UseViewModelInstanceAsyncResult;
+
+// Implementation
+export function useViewModelInstanceAsync(
+ source: ViewModelSource | null | undefined,
+ params?:
+ | UseViewModelInstanceFileParams
+ | UseViewModelInstanceViewModelParams
+ | UseViewModelInstanceRefParams
+): UseViewModelInstanceAsyncResult {
+ const fileInstanceName = (params as { instanceName?: string } | undefined)
+ ?.instanceName;
+ const viewModelInstanceName = (params as { name?: string } | undefined)?.name;
+ const instanceName = fileInstanceName ?? viewModelInstanceName;
+ const artboardName = (params as UseViewModelInstanceFileParams | undefined)
+ ?.artboardName;
+ const viewModelName = (params as UseViewModelInstanceFileParams | undefined)
+ ?.viewModelName;
+ const useNew =
+ (params as UseViewModelInstanceViewModelParams | undefined)?.useNew ??
+ false;
+ const required = params?.required ?? false;
+ const onInit = params?.onInit;
+
+ const onInitRef = useRef(onInit);
+ onInitRef.current = onInit;
+
+ const [result, setResult] =
+ useState(LOADING_RESULT);
+
+ useEffect(() => {
+ // Reset to the loading state whenever the inputs change so we never expose a
+ // stale (and about-to-be-disposed) instance from a previous resolution.
+ setResult((prev) => (prev.isLoading ? prev : LOADING_RESULT));
+
+ if (!source) {
+ return;
+ }
+
+ let cancelled = false;
+ let created: CreateInstanceResult | null = null;
+
+ (async () => {
+ try {
+ const c = await createInstanceAsync(
+ source,
+ instanceName,
+ artboardName,
+ viewModelName,
+ useNew
+ );
+ created = c;
+
+ if (cancelled) {
+ if (c.needsDispose && c.instance) callDispose(c.instance);
+ return;
+ }
+
+ if (c.instance) {
+ try {
+ onInitRef.current?.(c.instance);
+ } catch (e) {
+ created = null;
+ if (c.needsDispose) callDispose(c.instance);
+ setResult({
+ instance: null,
+ isLoading: false,
+ error: e instanceof Error ? e : new Error(String(e)),
+ });
+ return;
+ }
+ setResult({ instance: c.instance, isLoading: false, error: null });
+ } else if (c.error) {
+ setResult({
+ instance: null,
+ isLoading: false,
+ error: new Error(c.error),
+ });
+ } else {
+ // Resolved, but there is genuinely no ViewModel (not an error).
+ setResult({ instance: null, isLoading: false, error: null });
+ }
+ } catch (e) {
+ if (cancelled) return;
+ setResult({
+ instance: null,
+ isLoading: false,
+ error:
+ e instanceof Error
+ ? e
+ : new Error('Failed to create ViewModel instance'),
+ });
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ if (created?.needsDispose && created.instance) {
+ callDispose(created.instance);
+ }
+ };
+ }, [source, instanceName, artboardName, viewModelName, useNew]);
+
+ if (required && result.instance === null && !result.isLoading) {
+ throw new Error(
+ result.error
+ ? `useViewModelInstanceAsync: ${result.error.message}`
+ : 'useViewModelInstanceAsync: Failed to get ViewModelInstance. ' +
+ 'Ensure the source has a valid ViewModel and instance available.'
+ );
+ }
+
+ return result;
+}
diff --git a/src/index.tsx b/src/index.tsx
index edcc5535..9280fa52 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -65,9 +65,14 @@ export { useRiveColor } from './hooks/useRiveColor';
export { useRiveTrigger } from './hooks/useRiveTrigger';
export { useRiveList } from './hooks/useRiveList';
export {
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentionally re-exported for backward compatibility; prefer useViewModelInstanceAsync
useViewModelInstance,
type UseViewModelInstanceResult,
} from './hooks/useViewModelInstance';
+export {
+ useViewModelInstanceAsync,
+ type UseViewModelInstanceAsyncResult,
+} from './hooks/useViewModelInstanceAsync';
export { useRiveFile, type UseRiveFileResult } from './hooks/useRiveFile';
export { type RiveFileInput } from './hooks/useRiveFile';
export { type SetValueAction } from './types';