diff --git a/example/__tests__/hooks.harness.tsx b/example/__tests__/hooks.harness.tsx index 380cd785..da91d12c 100644 --- a/example/__tests__/hooks.harness.tsx +++ b/example/__tests__/hooks.harness.tsx @@ -26,14 +26,15 @@ type UseRiveNumberContext = { value: number | undefined; error: Error | null; setValue: ((v: number) => void) | null; + renderValues: (number | undefined)[]; }; function createUseRiveNumberContext(): UseRiveNumberContext { - return { value: undefined, error: null, setValue: null }; + return { value: undefined, error: null, setValue: null, renderValues: [] }; } type UseViewModelInstanceContext = { - instance: ViewModelInstance | null; + instance: ViewModelInstance | null | undefined; age: number | undefined; }; @@ -50,6 +51,8 @@ function UseRiveNumberTestComponent({ }) { const { value, setValue, error } = useRiveNumber('health', instance); + context.renderValues.push(value); + useEffect(() => { context.value = value; context.error = error; @@ -70,7 +73,7 @@ function UseViewModelInstanceTestComponent({ file: RiveFile; context: UseViewModelInstanceContext; }) { - const instance = useViewModelInstance(file); + const { instance } = useViewModelInstance(file); const age = useMemo(() => { if (!instance) return undefined; @@ -96,6 +99,34 @@ function expectDefined(value: T): asserts value is NonNullable { } 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(); + + await render( + + ); + + // First render must produce undefined — not a synchronous read from property.value + expect(context.renderValues[0]).toBeUndefined(); + + // 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(); diff --git a/example/src/demos/DataBindingArtboardsExample.tsx b/example/src/demos/DataBindingArtboardsExample.tsx index 3de225be..e2748bed 100644 --- a/example/src/demos/DataBindingArtboardsExample.tsx +++ b/example/src/demos/DataBindingArtboardsExample.tsx @@ -62,7 +62,7 @@ export default function DataBindingArtboardsExample() { return ( - {error || 'Failed to load Rive files'} + {error?.message || 'Failed to load Rive files'} ); @@ -78,7 +78,7 @@ function ArtboardSwapper({ mainFile: RiveFile; assetsFile: RiveFile; }) { - const instance = useViewModelInstance(mainFile); + const { instance, error } = useViewModelInstance(mainFile); const [currentArtboard, setCurrentArtboard] = useState('Dragon'); const initializedRef = useRef(false); @@ -98,6 +98,11 @@ function ArtboardSwapper({ } }, [instance, assetsFile]); + if (error) { + console.error(error.message); + return {error.message}; + } + // Map display names to actual artboard names const artboardOptions = [ { label: 'Dragon', artboard: 'Character 1', fromAssets: true }, diff --git a/example/src/demos/QuickStart.tsx b/example/src/demos/QuickStart.tsx index 4e167183..4b658262 100644 --- a/example/src/demos/QuickStart.tsx +++ b/example/src/demos/QuickStart.tsx @@ -23,7 +23,7 @@ export default function QuickStart() { require('../../assets/rive/quick_start.riv') ); const { riveViewRef, setHybridRef } = useRive(); - const viewModelInstance = useViewModelInstance(riveFile, { + const { instance: viewModelInstance } = useViewModelInstance(riveFile, { onInit: (vmi) => vmi.numberProperty('health')!.set(9), }); diff --git a/example/src/exercisers/FontFallbackExample.tsx b/example/src/exercisers/FontFallbackExample.tsx index 206f9c49..931593d6 100644 --- a/example/src/exercisers/FontFallbackExample.tsx +++ b/example/src/exercisers/FontFallbackExample.tsx @@ -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 ?? null); + const { instance } = useViewModelInstance(riveFile); const { setValue: setRiveText, error: textError } = useRiveString( TEXT_PROPERTY, @@ -285,7 +285,9 @@ function MountedView({ text }: { text: string }) { if (error || !riveFile) { return ( - {error || 'Failed to load file'} + + {error?.message || 'Failed to load file'} + ); } diff --git a/example/src/exercisers/MenuListExample.tsx b/example/src/exercisers/MenuListExample.tsx index 323bf473..d81ce97c 100644 --- a/example/src/exercisers/MenuListExample.tsx +++ b/example/src/exercisers/MenuListExample.tsx @@ -32,14 +32,21 @@ export default function MenuListExample() { ) : riveFile ? ( ) : ( - {error || 'Unexpected error'} + + {error?.message || 'Unexpected error'} + )} ); } function MenuList({ file }: { file: RiveFile }) { - const instance = useViewModelInstance(file, { required: true }); + const { instance, error } = useViewModelInstance(file); + + if (error) { + console.error(error.message); + return {error.message}; + } if (!instance) { return ; diff --git a/example/src/exercisers/NestedViewModelExample.tsx b/example/src/exercisers/NestedViewModelExample.tsx index efa997b4..fc3f4d24 100644 --- a/example/src/exercisers/NestedViewModelExample.tsx +++ b/example/src/exercisers/NestedViewModelExample.tsx @@ -31,14 +31,21 @@ export default function NestedViewModelExample() { ) : riveFile ? ( ) : ( - {error || 'Unexpected error'} + + {error?.message || 'Unexpected error'} + )} ); } function WithViewModelSetup({ file }: { file: RiveFile }) { - const instance = useViewModelInstance(file); + const { instance, error } = useViewModelInstance(file); + + if (error) { + console.error(error.message); + return {error.message}; + } if (!instance) { return ; diff --git a/example/src/exercisers/OutOfBandAssets.tsx b/example/src/exercisers/OutOfBandAssets.tsx index 7c3fe7e3..050dbf27 100644 --- a/example/src/exercisers/OutOfBandAssets.tsx +++ b/example/src/exercisers/OutOfBandAssets.tsx @@ -49,10 +49,10 @@ export default function OutOfBandAssetsExample() { if (isLoading) { return ; - } else if (error != null) { + } else if (error) { return ( - Error loading Rive file + Error loading Rive file: {error.message} ); } diff --git a/example/src/exercisers/OutOfBandAssetsWithSuspense.tsx b/example/src/exercisers/OutOfBandAssetsWithSuspense.tsx index 383c470e..d7313921 100644 --- a/example/src/exercisers/OutOfBandAssetsWithSuspense.tsx +++ b/example/src/exercisers/OutOfBandAssetsWithSuspense.tsx @@ -102,10 +102,10 @@ function RiveContent({ imageUrl }: { imageUrl: ImageURLS }) { if (isLoading) { return ; - } else if (error != null) { + } else if (error) { return ( - Error loading Rive file: {error} + Error loading Rive file: {error.message} ); } diff --git a/example/src/exercisers/ResponsiveLayouts.tsx b/example/src/exercisers/ResponsiveLayouts.tsx index 92132ad4..06635810 100644 --- a/example/src/exercisers/ResponsiveLayouts.tsx +++ b/example/src/exercisers/ResponsiveLayouts.tsx @@ -46,7 +46,7 @@ export default function ResponsiveLayoutsExample() { {isLoading ? ( ) : error ? ( - {error} + {error.message} ) : riveFile ? ( (riveRef.current = ref) }} diff --git a/example/src/exercisers/RiveDataBindingExample.tsx b/example/src/exercisers/RiveDataBindingExample.tsx index 42b0b1a2..825c81f7 100644 --- a/example/src/exercisers/RiveDataBindingExample.tsx +++ b/example/src/exercisers/RiveDataBindingExample.tsx @@ -27,7 +27,9 @@ export default function WithRiveFile() { ) : riveFile ? ( ) : ( - {error || 'Unexpected error'} + + {error?.message || 'Unexpected error'} + )} @@ -35,7 +37,12 @@ export default function WithRiveFile() { } function WithViewModelSetup({ file }: { file: RiveFile }) { - const instance = useViewModelInstance(file); + const { instance, error } = useViewModelInstance(file); + + if (error) { + console.error(error.message); + return {error.message}; + } if (!instance) { return ; diff --git a/example/src/exercisers/RiveDataBindingExampleExpApi.tsx b/example/src/exercisers/RiveDataBindingExampleExpApi.tsx index 8f4c2704..66c9d5dd 100644 --- a/example/src/exercisers/RiveDataBindingExampleExpApi.tsx +++ b/example/src/exercisers/RiveDataBindingExampleExpApi.tsx @@ -26,7 +26,9 @@ export default function WithRiveFile() { ) : riveFile ? ( ) : ( - {error || 'Unexpected error'} + + {error?.message || 'Unexpected error'} + )} diff --git a/example/src/exercisers/RiveEventsExample.tsx b/example/src/exercisers/RiveEventsExample.tsx index e6eec0d8..213863f9 100644 --- a/example/src/exercisers/RiveEventsExample.tsx +++ b/example/src/exercisers/RiveEventsExample.tsx @@ -50,7 +50,7 @@ export default function EventsExample() { {isLoading ? ( ) : error ? ( - {error} + {error.message} ) : riveFile ? ( { {!input || isLoading ? ( ) : error ? ( - {error} + {error.message} ) : riveFile ? ( ) : error ? ( - {error} + {error.message} ) : riveFile ? ( ) : error ? ( - {error} + {error.message} ) : riveFile ? ( { ); await waitFor(() => { - expect((result.current as any).isLoading).toBe(false); + expect(result.current.isLoading).toBe(false); }); const callCountBefore = (global as any).mockRiveFileFactory.fromURL.mock @@ -120,7 +120,7 @@ describe('useRiveFile - updateReferencedAssets', () => { ); await waitFor(() => { - expect((result.current as any).isLoading).toBe(false); + expect(result.current.isLoading).toBe(false); }); expect(mockRiveFile.updateReferencedAssets).not.toHaveBeenCalled(); @@ -155,7 +155,7 @@ describe('useRiveFile - updateReferencedAssets', () => { ); await waitFor(() => { - expect((result.current as any).isLoading).toBe(false); + expect(result.current.isLoading).toBe(false); }); const updatedAssets = { @@ -189,7 +189,7 @@ describe('useRiveFile - updateReferencedAssets', () => { ); await waitFor(() => { - expect((result.current as any).isLoading).toBe(false); + expect(result.current.isLoading).toBe(false); }); rerender({ referencedAssets: assets }); diff --git a/src/hooks/__tests__/useRiveProperty.test.ts b/src/hooks/__tests__/useRiveProperty.test.ts index b7314b19..7d280d8b 100644 --- a/src/hooks/__tests__/useRiveProperty.test.ts +++ b/src/hooks/__tests__/useRiveProperty.test.ts @@ -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; }; @@ -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, @@ -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'); }); diff --git a/src/hooks/__tests__/useViewModelInstance.test.ts b/src/hooks/__tests__/useViewModelInstance.test.ts index 1e6382b2..6b8680e3 100644 --- a/src/hooks/__tests__/useViewModelInstance.test.ts +++ b/src/hooks/__tests__/useViewModelInstance.test.ts @@ -86,7 +86,8 @@ describe('useViewModelInstance - RiveFile with instanceName parameter', () => { 'PersonInstance' ); expect(defaultViewModel.createDefaultInstance).not.toHaveBeenCalled(); - expect(result.current).toBe(personInstance); + expect(result.current.instance).toBe(personInstance); + expect(result.current.error).toBeNull(); }); it('should use defaultArtboardViewModel and createDefaultInstance when no instanceName provided', () => { @@ -102,10 +103,11 @@ describe('useViewModelInstance - RiveFile with instanceName parameter', () => { ); expect(defaultViewModel.createDefaultInstance).toHaveBeenCalled(); expect(defaultViewModel.createInstanceByName).not.toHaveBeenCalled(); - expect(result.current).toBe(defaultInstance); + expect(result.current.instance).toBe(defaultInstance); + expect(result.current.error).toBeNull(); }); - it('should return null when instance name not found and required is false', () => { + it('should return error when instance name not found and required is false', () => { const defaultViewModel = createMockViewModel({ namedInstances: {}, }); @@ -116,7 +118,9 @@ describe('useViewModelInstance - RiveFile with instanceName parameter', () => { useViewModelInstance(mockRiveFile, { instanceName: 'NonExistent' }) ); - expect(result.current).toBeNull(); + expect(result.current.instance).toBeNull(); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toContain('NonExistent'); }); it('should throw when instance name not found and required is true', () => { @@ -136,7 +140,7 @@ describe('useViewModelInstance - RiveFile with instanceName parameter', () => { ).toThrow("ViewModel instance 'NonExistent' not found"); }); - it('should return null when artboardName not found and required is false', () => { + it('should return error when artboardName not found and required is false', () => { const mockRiveFile = createMockRiveFile({ artboardViewModels: {}, }); @@ -145,7 +149,9 @@ describe('useViewModelInstance - RiveFile with instanceName parameter', () => { useViewModelInstance(mockRiveFile, { artboardName: 'MissingArtboard' }) ); - expect(result.current).toBeNull(); + expect(result.current.instance).toBeNull(); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toContain('MissingArtboard'); }); it('should throw when artboardName not found and required is true', () => { @@ -203,7 +209,8 @@ describe('useViewModelInstance - RiveFile with artboardName parameter', () => { name: 'MainArtboard', }); expect(mainArtboardViewModel.createDefaultInstance).toHaveBeenCalled(); - expect(result.current).toBe(mainInstance); + expect(result.current.instance).toBe(mainInstance); + expect(result.current.error).toBeNull(); }); it('should combine artboardName and instanceName to get specific instance from specific artboard', () => { @@ -230,7 +237,8 @@ describe('useViewModelInstance - RiveFile with artboardName parameter', () => { expect(mainArtboardViewModel.createInstanceByName).toHaveBeenCalledWith( 'SpecificInstance' ); - expect(result.current).toBe(specificInstance); + expect(result.current.instance).toBe(specificInstance); + expect(result.current.error).toBeNull(); }); }); @@ -252,10 +260,11 @@ describe('useViewModelInstance - RiveFile with viewModelName parameter', () => { expect(mockRiveFile.viewModelByName).toHaveBeenCalledWith('Settings'); expect(mockRiveFile.defaultArtboardViewModel).not.toHaveBeenCalled(); expect(settingsViewModel.createDefaultInstance).toHaveBeenCalled(); - expect(result.current).toBe(settingsInstance); + expect(result.current.instance).toBe(settingsInstance); + expect(result.current.error).toBeNull(); }); - it('should return null when viewModelName not found and required is false', () => { + it('should return error when viewModelName not found and required is false', () => { const mockRiveFile = createMockRiveFile({ namedViewModels: {}, }); @@ -264,7 +273,9 @@ describe('useViewModelInstance - RiveFile with viewModelName parameter', () => { useViewModelInstance(mockRiveFile, { viewModelName: 'NonExistent' }) ); - expect(result.current).toBeNull(); + expect(result.current.instance).toBeNull(); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toContain('NonExistent'); }); it('should throw when viewModelName not found and required is true', () => { @@ -303,7 +314,8 @@ describe('useViewModelInstance - RiveFile with viewModelName parameter', () => { expect(settingsViewModel.createInstanceByName).toHaveBeenCalledWith( 'UserSettings' ); - expect(result.current).toBe(specificInstance); + expect(result.current.instance).toBe(specificInstance); + expect(result.current.error).toBeNull(); }); }); @@ -320,7 +332,8 @@ describe('useViewModelInstance - ViewModel source', () => { expect(mockViewModel.createInstanceByName).toHaveBeenCalledWith('Gordon'); expect(mockViewModel.createDefaultInstance).not.toHaveBeenCalled(); - expect(result.current).toBe(namedInstance); + expect(result.current.instance).toBe(namedInstance); + expect(result.current.error).toBeNull(); }); it('should use createInstance when useNew is true', () => { @@ -334,7 +347,8 @@ describe('useViewModelInstance - ViewModel source', () => { expect(mockViewModel.createInstance).toHaveBeenCalled(); expect(mockViewModel.createDefaultInstance).not.toHaveBeenCalled(); - expect(result.current).toBe(newInstance); + expect(result.current.instance).toBe(newInstance); + expect(result.current.error).toBeNull(); }); it('should use createDefaultInstance when no params provided', () => { @@ -344,6 +358,16 @@ describe('useViewModelInstance - ViewModel source', () => { const { result } = renderHook(() => useViewModelInstance(mockViewModel)); expect(mockViewModel.createDefaultInstance).toHaveBeenCalled(); - expect(result.current).toBe(defaultInstance); + expect(result.current.instance).toBe(defaultInstance); + expect(result.current.error).toBeNull(); + }); +}); + +describe('useViewModelInstance - null source', () => { + it('should return undefined instance when source is null', () => { + const { result } = renderHook(() => useViewModelInstance(null)); + + expect(result.current.instance).toBeUndefined(); + expect(result.current.error).toBeNull(); }); }); diff --git a/src/hooks/useRiveFile.ts b/src/hooks/useRiveFile.ts index 81f6e18e..b9d5d83e 100644 --- a/src/hooks/useRiveFile.ts +++ b/src/hooks/useRiveFile.ts @@ -87,17 +87,17 @@ function transformFilesHandledMapping( return transformedMapping; } -type RiveFileHookResult = +export type UseRiveFileResult = | { riveFile: RiveFile; isLoading: false; error: null } - | { riveFile: null; isLoading: true; error: null } - | { riveFile: null; isLoading: false; error: string }; + | { riveFile: null; isLoading: false; error: Error } + | { riveFile: undefined; isLoading: true; error: null }; export function useRiveFile( input: RiveFileInput | undefined, options: UseRiveFileOptions = {} -): RiveFileHookResult { - const [result, setResult] = useState({ - riveFile: null, +): UseRiveFileResult { + const [result, setResult] = useState({ + riveFile: undefined, isLoading: true, error: null, }); @@ -124,7 +124,7 @@ export function useRiveFile( setResult({ riveFile: null, isLoading: false, - error: 'No Rive file input provided.', + error: new Error('No Rive file input provided.'), }); return; } @@ -162,9 +162,7 @@ export function useRiveFile( riveFile: null, isLoading: false, error: - err instanceof Error - ? err.message || 'Unknown error' - : 'Failed to load Rive file', + err instanceof Error ? err : new Error('Failed to load Rive file'), }); } }; @@ -192,5 +190,5 @@ export function useRiveFile( riveFile: result.riveFile, isLoading: result.isLoading, error: result.error, - } as RiveFileHookResult; + } as UseRiveFileResult; } diff --git a/src/hooks/useRiveProperty.ts b/src/hooks/useRiveProperty.ts index 9ab6f32f..6c13fc50 100644 --- a/src/hooks/useRiveProperty.ts +++ b/src/hooks/useRiveProperty.ts @@ -34,7 +34,6 @@ export function useRiveProperty

( Error | null, P | undefined, ] { - // Get the property first so we can read its initial value const property = useMemo(() => { if (!viewModelInstance) return; return options.getProperty( @@ -43,17 +42,12 @@ export function useRiveProperty

( ) as unknown as ObservableViewModelProperty; }, [options, viewModelInstance, path]); - // Initialize state with property's current value (if available) - const [value, setValue] = useState(() => 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(undefined); const [error, setError] = useState(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); @@ -72,8 +66,14 @@ export function useRiveProperty

( 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) => { @@ -86,7 +86,9 @@ export function useRiveProperty

( }; }, [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) { @@ -94,14 +96,12 @@ export function useRiveProperty

( } 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]; diff --git a/src/hooks/useViewModelInstance.ts b/src/hooks/useViewModelInstance.ts index 8534e473..77227e9d 100644 --- a/src/hooks/useViewModelInstance.ts +++ b/src/hooks/useViewModelInstance.ts @@ -88,29 +88,33 @@ export type UseViewModelInstanceRefParams = UseViewModelInstanceBaseParams; type ViewModelSource = ViewModel | RiveFile | RiveViewRef; -function isRiveViewRef(source: ViewModelSource | null): source is RiveViewRef { - return source !== null && 'getViewModelInstance' in source; +function isRiveViewRef( + source: ViewModelSource | null | undefined +): source is RiveViewRef { + return source != null && 'getViewModelInstance' in source; } -function isRiveFile(source: ViewModelSource | null): source is RiveFile { - return source !== null && 'defaultArtboardViewModel' in source; +function isRiveFile( + source: ViewModelSource | null | undefined +): source is RiveFile { + return source != null && 'defaultArtboardViewModel' in source; } type CreateInstanceResult = { - instance: ViewModelInstance | null; + instance: ViewModelInstance | null | undefined; needsDispose: boolean; error?: string; }; function createInstance( - source: ViewModelSource | null, + source: ViewModelSource | null | undefined, instanceName: string | undefined, artboardName: string | undefined, viewModelName: string | undefined, useNew: boolean ): CreateInstanceResult { if (!source) { - return { instance: null, needsDispose: false }; + return { instance: undefined, needsDispose: false }; } if (isRiveViewRef(source)) { @@ -176,119 +180,137 @@ function createInstance( return { instance: vmi ?? null, needsDispose: true }; } +export type UseViewModelInstanceResult = + | { instance: ViewModelInstance; error: null } + | { instance: null; error: Error } + | { instance: null; error: null } + | { instance: undefined; error: null }; + /** * Hook for getting a ViewModelInstance from a RiveFile, ViewModel, or RiveViewRef. * * @param source - The RiveFile, ViewModel, or RiveViewRef to get an instance from * @param params - Configuration for which instance to retrieve - * @returns The ViewModelInstance or null if not found + * @returns An object with `instance` and `error` (discriminated union) * * @example * ```tsx * // From RiveFile (get default instance) * const { riveFile } = useRiveFile(require('./animation.riv')); - * const instance = useViewModelInstance(riveFile); + * const { instance } = useViewModelInstance(riveFile); * ``` * * @example * ```tsx * // From RiveFile with specific instance name * const { riveFile } = useRiveFile(require('./animation.riv')); - * const instance = useViewModelInstance(riveFile, { instanceName: 'PersonInstance' }); + * const { instance } = useViewModelInstance(riveFile, { instanceName: 'PersonInstance' }); * ``` * * @example * ```tsx * // From RiveFile with specific ViewModel name * const { riveFile } = useRiveFile(require('./animation.riv')); - * const instance = useViewModelInstance(riveFile, { viewModelName: 'Settings' }); + * const { instance } = useViewModelInstance(riveFile, { viewModelName: 'Settings' }); * ``` * * @example * ```tsx * // From RiveFile with specific artboard * const { riveFile } = useRiveFile(require('./animation.riv')); - * const instance = useViewModelInstance(riveFile, { artboardName: 'MainArtboard' }); + * const { instance } = useViewModelInstance(riveFile, { artboardName: 'MainArtboard' }); * ``` * * @example * ```tsx * // From RiveViewRef (get auto-bound instance) * const { riveViewRef, setHybridRef } = useRive(); - * const instance = useViewModelInstance(riveViewRef); + * const { instance } = useViewModelInstance(riveViewRef); * ``` * * @example * ```tsx * // From ViewModel * const viewModel = file.viewModelByName('main'); - * const instance = useViewModelInstance(viewModel); + * const { instance } = useViewModelInstance(viewModel); * ``` * * @example * ```tsx * // Create a new blank instance from ViewModel * const viewModel = file.viewModelByName('TodoItem'); - * const newInstance = useViewModelInstance(viewModel, { useNew: true }); + * const { instance } = useViewModelInstance(viewModel, { useNew: true }); * ``` * * @example * ```tsx * // With required: true (throws if null, use with Error Boundary) - * const instance = useViewModelInstance(riveFile, { required: true }); + * const { instance } = useViewModelInstance(riveFile, { required: true }); * // instance is guaranteed to be non-null here * ``` * * @example * ```tsx * // With onInit to set initial values synchronously - * const instance = useViewModelInstance(riveFile, { + * const { instance } = useViewModelInstance(riveFile, { * onInit: (vmi) => { * vmi.numberProperty('count').set(initialCount); * vmi.stringProperty('name').set(userName); * } * }); - * // Values are already set here + * ``` + * + * @example + * ```tsx + * // Error handling + * const { instance, error } = useViewModelInstance(riveFile, { viewModelName: 'Missing' }); + * if (error) console.error(error.message); * ``` */ // RiveFile overloads export function useViewModelInstance( source: RiveFile, params: UseViewModelInstanceFileParams & { required: true } -): ViewModelInstance; +): + | { instance: ViewModelInstance; error: null } + | { instance: undefined; error: null }; export function useViewModelInstance( - source: RiveFile | null, + source: RiveFile | null | undefined, params?: UseViewModelInstanceFileParams -): ViewModelInstance | null; +): UseViewModelInstanceResult; // ViewModel overloads export function useViewModelInstance( source: ViewModel, params: UseViewModelInstanceViewModelParams & { required: true } -): ViewModelInstance; +): + | { instance: ViewModelInstance; error: null } + | { instance: undefined; error: null }; export function useViewModelInstance( - source: ViewModel | null, + source: ViewModel | null | undefined, params?: UseViewModelInstanceViewModelParams -): ViewModelInstance | null; +): UseViewModelInstanceResult; // RiveViewRef overloads export function useViewModelInstance( source: RiveViewRef, params: UseViewModelInstanceRefParams & { required: true } -): ViewModelInstance; +): + | { instance: ViewModelInstance; error: null } + | { instance: undefined; error: null }; export function useViewModelInstance( - source: RiveViewRef | null, + source: RiveViewRef | null | undefined, params?: UseViewModelInstanceRefParams -): ViewModelInstance | null; +): UseViewModelInstanceResult; // Implementation export function useViewModelInstance( - source: ViewModelSource | null, + source: ViewModelSource | null | undefined, params?: | UseViewModelInstanceFileParams | UseViewModelInstanceViewModelParams | UseViewModelInstanceRefParams -): ViewModelInstance | null { +): UseViewModelInstanceResult { const fileInstanceName = (params as { instanceName?: string } | undefined) ?.instanceName; const viewModelInstanceName = (params as { name?: string } | undefined)?.name; @@ -304,7 +326,7 @@ export function useViewModelInstance( const onInit = params?.onInit; const prevInstanceRef = useRef<{ - instance: ViewModelInstance | null; + instance: ViewModelInstance | null | undefined; needsDispose: boolean; } | null>(null); @@ -347,6 +369,11 @@ export function useViewModelInstance( }; }, []); + const error = useMemo( + () => (result.error ? new Error(result.error) : null), + [result.error] + ); + if (required && result.instance === null) { throw new Error( result.error @@ -356,5 +383,11 @@ export function useViewModelInstance( ); } - return result.instance; + if (result.instance) { + return { instance: result.instance, error: null }; + } + if (result.instance === undefined) { + return { instance: undefined, error: null }; + } + return { instance: null, error }; } diff --git a/src/index.tsx b/src/index.tsx index 28756ac0..7e29e27a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -58,8 +58,11 @@ export { useRiveEnum } from './hooks/useRiveEnum'; export { useRiveColor } from './hooks/useRiveColor'; export { useRiveTrigger } from './hooks/useRiveTrigger'; export { useRiveList } from './hooks/useRiveList'; -export { useViewModelInstance } from './hooks/useViewModelInstance'; -export { useRiveFile } from './hooks/useRiveFile'; +export { + useViewModelInstance, + type UseViewModelInstanceResult, +} from './hooks/useViewModelInstance'; +export { useRiveFile, type UseRiveFileResult } from './hooks/useRiveFile'; export { type RiveFileInput } from './hooks/useRiveFile'; export { type SetValueAction } from './types'; export { RiveRuntime } from './core/RiveRuntime';