From 049e271f581f4a6cdaca121b54516fb1b59edc13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 27 Mar 2026 16:25:52 +0100 Subject: [PATCH 1/2] fix!: hooks start undefined, useViewModelInstance returns {instance, error} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useRiveNumber/String/Boolean/Color/Enum hooks start as undefined — the real value arrives via listener. useViewModelInstance returns a discriminated union: undefined (loading), null (resolved empty/error), or ViewModelInstance (success). --- example/__tests__/hooks.harness.tsx | 37 +++++++++- .../src/demos/DataBindingArtboardsExample.tsx | 7 +- example/src/demos/QuickStart.tsx | 2 +- .../src/exercisers/FontFallbackExample.tsx | 2 +- example/src/exercisers/MenuListExample.tsx | 7 +- .../src/exercisers/NestedViewModelExample.tsx | 7 +- .../src/exercisers/RiveDataBindingExample.tsx | 7 +- src/hooks/__tests__/useRiveProperty.test.ts | 9 ++- .../__tests__/useViewModelInstance.test.ts | 54 ++++++++++---- src/hooks/useRiveProperty.ts | 34 ++++----- src/hooks/useViewModelInstance.ts | 70 +++++++++++++------ src/index.tsx | 5 +- 12 files changed, 176 insertions(+), 65 deletions(-) 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..906b8829 100644 --- a/example/src/demos/DataBindingArtboardsExample.tsx +++ b/example/src/demos/DataBindingArtboardsExample.tsx @@ -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..d4f2f4fc 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 ?? null); const { setValue: setRiveText, error: textError } = useRiveString( TEXT_PROPERTY, diff --git a/example/src/exercisers/MenuListExample.tsx b/example/src/exercisers/MenuListExample.tsx index 323bf473..d8356fef 100644 --- a/example/src/exercisers/MenuListExample.tsx +++ b/example/src/exercisers/MenuListExample.tsx @@ -39,7 +39,12 @@ export default function MenuListExample() { } 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..a4ab0f0b 100644 --- a/example/src/exercisers/NestedViewModelExample.tsx +++ b/example/src/exercisers/NestedViewModelExample.tsx @@ -38,7 +38,12 @@ export default function NestedViewModelExample() { } 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/RiveDataBindingExample.tsx b/example/src/exercisers/RiveDataBindingExample.tsx index 42b0b1a2..65a051b2 100644 --- a/example/src/exercisers/RiveDataBindingExample.tsx +++ b/example/src/exercisers/RiveDataBindingExample.tsx @@ -35,7 +35,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/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/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..da780f21 100644 --- a/src/hooks/useViewModelInstance.ts +++ b/src/hooks/useViewModelInstance.ts @@ -97,7 +97,7 @@ function isRiveFile(source: ViewModelSource | null): source is RiveFile { } type CreateInstanceResult = { - instance: ViewModelInstance | null; + instance: ViewModelInstance | null | undefined; needsDispose: boolean; error?: string; }; @@ -110,7 +110,7 @@ function createInstance( useNew: boolean ): CreateInstanceResult { if (!source) { - return { instance: null, needsDispose: false }; + return { instance: undefined, needsDispose: false }; } if (isRiveViewRef(source)) { @@ -176,110 +176,128 @@ 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, 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, 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, params?: UseViewModelInstanceRefParams -): ViewModelInstance | null; +): UseViewModelInstanceResult; // Implementation export function useViewModelInstance( @@ -288,7 +306,7 @@ export function useViewModelInstance( | UseViewModelInstanceFileParams | UseViewModelInstanceViewModelParams | UseViewModelInstanceRefParams -): ViewModelInstance | null { +): UseViewModelInstanceResult { const fileInstanceName = (params as { instanceName?: string } | undefined) ?.instanceName; const viewModelInstanceName = (params as { name?: string } | undefined)?.name; @@ -304,7 +322,7 @@ export function useViewModelInstance( const onInit = params?.onInit; const prevInstanceRef = useRef<{ - instance: ViewModelInstance | null; + instance: ViewModelInstance | null | undefined; needsDispose: boolean; } | null>(null); @@ -347,6 +365,8 @@ export function useViewModelInstance( }; }, []); + const error = result.error ? new Error(result.error) : null; + if (required && result.instance === null) { throw new Error( result.error @@ -356,5 +376,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..85788190 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -58,7 +58,10 @@ 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 { + useViewModelInstance, + type UseViewModelInstanceResult, +} from './hooks/useViewModelInstance'; export { useRiveFile } from './hooks/useRiveFile'; export { type RiveFileInput } from './hooks/useRiveFile'; export { type SetValueAction } from './types'; From 7116ac7ad94bbe9967ba350bb675e458480d041a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Mon, 30 Mar 2026 14:10:38 +0200 Subject: [PATCH 2/2] fix!: useRiveFile error is now Error, riveFile undefined while loading Align useRiveFile with useViewModelInstance semantics: riveFile is undefined while loading (was null), error is Error object (was string). isLoading kept for convenience. Memoize error in useViewModelInstance, accept undefined source to avoid ?? null at call sites. --- .../src/demos/DataBindingArtboardsExample.tsx | 2 +- .../src/exercisers/FontFallbackExample.tsx | 6 +++-- example/src/exercisers/MenuListExample.tsx | 4 ++- .../src/exercisers/NestedViewModelExample.tsx | 4 ++- example/src/exercisers/OutOfBandAssets.tsx | 4 +-- .../OutOfBandAssetsWithSuspense.tsx | 4 +-- example/src/exercisers/ResponsiveLayouts.tsx | 2 +- .../src/exercisers/RiveDataBindingExample.tsx | 4 ++- .../RiveDataBindingExampleExpApi.tsx | 4 ++- example/src/exercisers/RiveEventsExample.tsx | 2 +- .../src/exercisers/RiveFileLoadingExample.tsx | 2 +- .../RiveStateMachineInputsExample.tsx | 2 +- example/src/exercisers/RiveTextRunExample.tsx | 2 +- src/hooks/__tests__/useRiveFile.test.ts | 8 +++--- src/hooks/useRiveFile.ts | 20 +++++++------- src/hooks/useViewModelInstance.ts | 27 ++++++++++++------- src/index.tsx | 2 +- 17 files changed, 57 insertions(+), 42 deletions(-) diff --git a/example/src/demos/DataBindingArtboardsExample.tsx b/example/src/demos/DataBindingArtboardsExample.tsx index 906b8829..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'} ); diff --git a/example/src/exercisers/FontFallbackExample.tsx b/example/src/exercisers/FontFallbackExample.tsx index d4f2f4fc..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 d8356fef..d81ce97c 100644 --- a/example/src/exercisers/MenuListExample.tsx +++ b/example/src/exercisers/MenuListExample.tsx @@ -32,7 +32,9 @@ export default function MenuListExample() { ) : riveFile ? ( ) : ( - {error || 'Unexpected error'} + + {error?.message || 'Unexpected error'} + )} ); diff --git a/example/src/exercisers/NestedViewModelExample.tsx b/example/src/exercisers/NestedViewModelExample.tsx index a4ab0f0b..fc3f4d24 100644 --- a/example/src/exercisers/NestedViewModelExample.tsx +++ b/example/src/exercisers/NestedViewModelExample.tsx @@ -31,7 +31,9 @@ export default function NestedViewModelExample() { ) : riveFile ? ( ) : ( - {error || 'Unexpected error'} + + {error?.message || 'Unexpected error'} + )} ); 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 65a051b2..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'} + )} 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/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/useViewModelInstance.ts b/src/hooks/useViewModelInstance.ts index da780f21..77227e9d 100644 --- a/src/hooks/useViewModelInstance.ts +++ b/src/hooks/useViewModelInstance.ts @@ -88,12 +88,16 @@ 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 = { @@ -103,7 +107,7 @@ type CreateInstanceResult = { }; function createInstance( - source: ViewModelSource | null, + source: ViewModelSource | null | undefined, instanceName: string | undefined, artboardName: string | undefined, viewModelName: string | undefined, @@ -271,7 +275,7 @@ export function useViewModelInstance( | { instance: ViewModelInstance; error: null } | { instance: undefined; error: null }; export function useViewModelInstance( - source: RiveFile | null, + source: RiveFile | null | undefined, params?: UseViewModelInstanceFileParams ): UseViewModelInstanceResult; @@ -283,7 +287,7 @@ export function useViewModelInstance( | { instance: ViewModelInstance; error: null } | { instance: undefined; error: null }; export function useViewModelInstance( - source: ViewModel | null, + source: ViewModel | null | undefined, params?: UseViewModelInstanceViewModelParams ): UseViewModelInstanceResult; @@ -295,13 +299,13 @@ export function useViewModelInstance( | { instance: ViewModelInstance; error: null } | { instance: undefined; error: null }; export function useViewModelInstance( - source: RiveViewRef | null, + source: RiveViewRef | null | undefined, params?: UseViewModelInstanceRefParams ): UseViewModelInstanceResult; // Implementation export function useViewModelInstance( - source: ViewModelSource | null, + source: ViewModelSource | null | undefined, params?: | UseViewModelInstanceFileParams | UseViewModelInstanceViewModelParams @@ -365,7 +369,10 @@ export function useViewModelInstance( }; }, []); - const error = result.error ? new Error(result.error) : null; + const error = useMemo( + () => (result.error ? new Error(result.error) : null), + [result.error] + ); if (required && result.instance === null) { throw new Error( diff --git a/src/index.tsx b/src/index.tsx index 85788190..7e29e27a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -62,7 +62,7 @@ export { useViewModelInstance, type UseViewModelInstanceResult, } from './hooks/useViewModelInstance'; -export { useRiveFile } from './hooks/useRiveFile'; +export { useRiveFile, type UseRiveFileResult } from './hooks/useRiveFile'; export { type RiveFileInput } from './hooks/useRiveFile'; export { type SetValueAction } from './types'; export { RiveRuntime } from './core/RiveRuntime';