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..0e74a11a 100644 --- a/example/src/demos/QuickStart.tsx +++ b/example/src/demos/QuickStart.tsx @@ -6,26 +6,29 @@ - Data Binding: https://rive.app/docs/runtimes/data-binding */ -import { Button, View, StyleSheet } from 'react-native'; +import { Button, Text, View, StyleSheet } from 'react-native'; import { RiveView, useRive, useRiveFile, useRiveNumber, useRiveTrigger, - useViewModelInstance, + useViewModelInstanceAsync, Fit, } from '@rive-app/react-native'; import type { Metadata } from '../shared/metadata'; export default function QuickStart() { - const { riveFile } = useRiveFile( + const { riveFile, error: fileError } = useRiveFile( require('../../assets/rive/quick_start.riv') ); const { riveViewRef, setHybridRef } = useRive(); - const { instance: viewModelInstance } = useViewModelInstance(riveFile, { - onInit: (vmi) => vmi.numberProperty('health')!.set(9), - }); + const { instance: viewModelInstance, error } = useViewModelInstanceAsync( + riveFile, + { + onInit: (vmi) => vmi.numberProperty('health')!.set(9), + } + ); const { setValue: setHealth } = useRiveNumber('health', viewModelInstance); @@ -51,6 +54,12 @@ export default function QuickStart() { riveViewRef!.play(); }; + if (fileError || error) { + const message = (fileError ?? error)!.message; + console.error(message); + return {message}; + } + return ( {riveFile && viewModelInstance && ( @@ -86,4 +95,7 @@ const styles = StyleSheet.create({ width: '100%', height: 400, }, + error: { + color: 'red', + }, }); 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 fc3f4d24..f579661f 100644 --- a/example/src/exercisers/NestedViewModelExample.tsx +++ b/example/src/exercisers/NestedViewModelExample.tsx @@ -12,7 +12,7 @@ import { RiveView, useRiveFile, useRiveString, - useViewModelInstance, + useViewModelInstanceAsync, type ViewModelInstance, type RiveFile, type RiveViewRef, @@ -40,7 +40,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/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx b/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx new file mode 100644 index 00000000..149106ec --- /dev/null +++ b/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx @@ -0,0 +1,438 @@ +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 - native rejections', () => { + // The new native backend rejects on a missing artboard (main's backend + // resolves undefined instead); the hook must map both to the same error. + it('maps an artboard lookup rejection to the artboard-not-found error', async () => { + const mockRiveFile = createMockRiveFile({}); + (mockRiveFile.defaultArtboardViewModelAsync as jest.Mock).mockRejectedValue( + new Error('No Artboard found with name MissingBoard.') + ); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile, { artboardName: 'MissingBoard' }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.instance).toBeNull(); + expect(result.current.error?.message).toContain( + "Artboard 'MissingBoard' not found" + ); + }); + + it('surfaces the underlying failure when createInstanceByNameAsync rejects (RiveFile source)', async () => { + const defaultViewModel = createMockViewModel(); + (defaultViewModel.createInstanceByNameAsync as jest.Mock).mockRejectedValue( + new Error('Cannot acquire a disposed object') + ); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile, { instanceName: 'Gordon' }) + ); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(result.current.instance).toBeNull(); + expect(result.current.error?.message).toContain( + 'Cannot acquire a disposed object' + ); + }); + + it('surfaces the underlying failure when createInstanceByNameAsync rejects (ViewModel source)', async () => { + const mockViewModel = createMockViewModel(); + (mockViewModel.createInstanceByNameAsync as jest.Mock).mockRejectedValue( + new Error('Cannot acquire a disposed object') + ); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockViewModel, { name: 'Gordon' }) + ); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(result.current.instance).toBeNull(); + expect(result.current.error?.message).toContain( + 'Cannot acquire a disposed object' + ); + }); +}); + +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 f5acea77..9015315c 100644 --- a/src/hooks/useViewModelInstance.ts +++ b/src/hooks/useViewModelInstance.ts @@ -185,6 +185,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..0bda52ab --- /dev/null +++ b/src/hooks/useViewModelInstanceAsync.ts @@ -0,0 +1,372 @@ +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; +}; + +function errorMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +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 { + // Some native backends reject on a missing artboard instead of + // resolving undefined — map both to the same friendly error. + try { + viewModel = await source.defaultArtboardViewModelAsync( + artboardName ? ArtboardByName(artboardName) : undefined + ); + } catch (e) { + if (!artboardName) throw e; + return { + instance: null, + needsDispose: false, + error: `Artboard '${artboardName}' not found or has no ViewModel (${errorMessage(e)})`, + }; + } + 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) { + return { + instance: null, + needsDispose: false, + error: `Failed to create ViewModel instance '${instanceName}': ${errorMessage(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) { + return { + instance: null, + needsDispose: false, + error: `Failed to create ViewModel instance '${instanceName}': ${errorMessage(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 { riveFile, error: fileError } = useRiveFile(require('./animation.riv')); + * const { instance, isLoading, error } = useViewModelInstanceAsync(riveFile); + * if (fileError || error) return ; + * if (isLoading || !instance) return ; + * // ... + * + * ``` + * + * Note: while `source` is `null`/`undefined` the hook stays in the loading + * state — it cannot tell "still loading" from "failed upstream". If the source + * comes from {@link useRiveFile}, check that hook's `error` as above; keying a + * spinner off this hook's `isLoading` alone would spin forever on a failed + * file load. + * + * @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 7e29e27a..d58704bf 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -59,9 +59,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';