From 24eb33364a1a9fabd1d84e73aff99e10b1cd17bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 23 Dec 2025 10:13:18 +0100 Subject: [PATCH] fix: read initial value in useRiveProperty hooks The hook initialized useState with undefined and only updated via listener callbacks which fire on changes, not on initial attachment. Now the property is retrieved first and its value is used to initialize state. --- src/hooks/__tests__/useRiveProperty.test.ts | 147 ++++++++++++++++++++ src/hooks/useRiveProperty.ts | 26 ++-- 2 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 src/hooks/__tests__/useRiveProperty.test.ts diff --git a/src/hooks/__tests__/useRiveProperty.test.ts b/src/hooks/__tests__/useRiveProperty.test.ts new file mode 100644 index 00000000..13459a8f --- /dev/null +++ b/src/hooks/__tests__/useRiveProperty.test.ts @@ -0,0 +1,147 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { useRiveProperty } from '../useRiveProperty'; +import type { ViewModelInstance } from '../../specs/ViewModel.nitro'; + +describe('useRiveProperty', () => { + const createMockProperty = (initialValue: string) => { + let currentValue = initialValue; + let listener: ((value: string) => void) | null = null; + + return { + get value() { + return currentValue; + }, + set value(newValue: string) { + currentValue = newValue; + listener?.(newValue); + }, + addListener: jest.fn((callback: (value: string) => void) => { + listener = callback; + return () => { + listener = null; + }; + }), + dispose: jest.fn(), + }; + }; + + const createMockViewModelInstance = ( + propertyMap: Record> + ) => { + return { + enumProperty: jest.fn((path: string) => propertyMap[path]), + numberProperty: jest.fn((path: string) => propertyMap[path]), + stringProperty: jest.fn((path: string) => propertyMap[path]), + booleanProperty: jest.fn((path: string) => propertyMap[path]), + } as unknown as ViewModelInstance; + }; + + it('should return initial value from property on first render', () => { + const mockProperty = createMockProperty('Tea'); + const mockInstance = createMockViewModelInstance({ + 'favDrink/type': mockProperty, + }); + + const { result } = renderHook(() => + useRiveProperty(mockInstance, 'favDrink/type', { + getProperty: (vmi, path) => (vmi as any).enumProperty(path), + }) + ); + + const [value] = result.current; + expect(value).toBe('Tea'); + }); + + it('should update value when property changes', () => { + const mockProperty = createMockProperty('Tea'); + const mockInstance = createMockViewModelInstance({ + 'favDrink/type': mockProperty, + }); + + const { result } = renderHook(() => + useRiveProperty(mockInstance, 'favDrink/type', { + getProperty: (vmi, path) => (vmi as any).enumProperty(path), + }) + ); + + act(() => { + mockProperty.value = 'Coffee'; + }); + + const [value] = result.current; + expect(value).toBe('Coffee'); + }); + + it('should return undefined when viewModelInstance is null', () => { + const { result } = renderHook(() => + useRiveProperty(null, 'favDrink/type', { + getProperty: (vmi, path) => (vmi as any).enumProperty(path), + }) + ); + + const [value] = result.current; + expect(value).toBeUndefined(); + }); + + it('should return error when property is not found', () => { + const mockInstance = createMockViewModelInstance({}); + + const { result } = renderHook(() => + useRiveProperty(mockInstance, 'nonexistent/path', { + getProperty: (vmi, path) => (vmi as any).enumProperty(path), + }) + ); + + const [, , error] = result.current; + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('nonexistent/path'); + }); + + it('should update value when path changes', () => { + const teaProperty = createMockProperty('Tea'); + const coffeeProperty = createMockProperty('Coffee'); + const mockInstance = createMockViewModelInstance({ + 'drinks/tea': teaProperty, + 'drinks/coffee': coffeeProperty, + }); + + const { result, rerender } = renderHook( + (props: { path: string }) => + useRiveProperty(mockInstance, props.path, { + getProperty: (vmi, p) => (vmi as any).enumProperty(p), + }), + { initialProps: { path: 'drinks/tea' } } + ); + + expect(result.current[0]).toBe('Tea'); + + rerender({ path: 'drinks/coffee' }); + + expect(result.current[0]).toBe('Coffee'); + }); + + it('should update value when viewModelInstance changes', () => { + const instance1Property = createMockProperty('Instance1Value'); + const instance2Property = createMockProperty('Instance2Value'); + const mockInstance1 = createMockViewModelInstance({ + 'prop/path': instance1Property, + }); + const mockInstance2 = createMockViewModelInstance({ + 'prop/path': instance2Property, + }); + + const { result, rerender } = renderHook( + (props: { instance: ViewModelInstance }) => + useRiveProperty(props.instance, 'prop/path', { + getProperty: (vmi, p) => (vmi as any).enumProperty(p), + }), + { initialProps: { instance: mockInstance1 } } + ); + + expect(result.current[0]).toBe('Instance1Value'); + + rerender({ instance: mockInstance2 }); + + expect(result.current[0]).toBe('Instance2Value'); + }); +}); diff --git a/src/hooks/useRiveProperty.ts b/src/hooks/useRiveProperty.ts index c74c002c..a49bdfe9 100644 --- a/src/hooks/useRiveProperty.ts +++ b/src/hooks/useRiveProperty.ts @@ -34,15 +34,7 @@ export function useRiveProperty

( Error | null, P | undefined, ] { - const [value, setValue] = useState(undefined); - const [error, setError] = useState(null); - - // Clear error when path or instance changes - useEffect(() => { - setError(null); - }, [path, viewModelInstance]); - - // Get the property + // Get the property first so we can read its initial value const property = useMemo(() => { if (!viewModelInstance) return; return options.getProperty( @@ -51,6 +43,22 @@ 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); + 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); + }, [path, viewModelInstance]); + // Set error if property is not found useEffect(() => { if (viewModelInstance && !property) {