From 532970bd3f35de664ed0755d30e8615b627b34d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 1 Jul 2026 07:27:30 +0200 Subject: [PATCH 1/7] feat(hooks): add async useViewModelInstanceAsync, deprecate sync hook The sync useViewModelInstance creates instances via deprecated runtime APIs that touch the Rive runtime on the JS thread. Add useViewModelInstanceAsync built on the non-deprecated `*Async` APIs, returning a { instance, isLoading, error } discriminated union (mirroring useRiveFile), with cancellation/disposal on deps change + unmount and onInit applied before the instance is exposed. Keeps `required` parity (throws once resolved to null; undefined while loading). The sync hook is marked @deprecated but left working for backward compatibility. Migrates the example screens to the async hook. --- .../src/demos/DataBindingArtboardsExample.tsx | 4 +- example/src/demos/QuickStart.tsx | 4 +- .../src/exercisers/FontFallbackExample.tsx | 4 +- example/src/exercisers/MenuListExample.tsx | 4 +- .../src/exercisers/NestedViewModelExample.tsx | 4 +- .../src/exercisers/RiveDataBindingExample.tsx | 4 +- .../src/reproducers/Issue297ThreadRace.tsx | 4 +- .../useViewModelInstanceAsync.test.tsx | 382 ++++++++++++++++++ src/hooks/useViewModelInstance.ts | 4 + src/hooks/useViewModelInstanceAsync.ts | 341 ++++++++++++++++ src/index.tsx | 5 + 11 files changed, 746 insertions(+), 14 deletions(-) create mode 100644 src/hooks/__tests__/useViewModelInstanceAsync.test.tsx create mode 100644 src/hooks/useViewModelInstanceAsync.ts diff --git a/example/src/demos/DataBindingArtboardsExample.tsx b/example/src/demos/DataBindingArtboardsExample.tsx index e2748bed..49cc9f36 100644 --- a/example/src/demos/DataBindingArtboardsExample.tsx +++ b/example/src/demos/DataBindingArtboardsExample.tsx @@ -10,7 +10,7 @@ import { Fit, RiveView, useRiveFile, - useViewModelInstance, + useViewModelInstanceAsync, type RiveFile, type BindableArtboard, } from '@rive-app/react-native'; @@ -78,7 +78,7 @@ function ArtboardSwapper({ mainFile: RiveFile; assetsFile: RiveFile; }) { - const { instance, error } = useViewModelInstance(mainFile); + const { instance, error } = useViewModelInstanceAsync(mainFile); const [currentArtboard, setCurrentArtboard] = useState('Dragon'); const initializedRef = useRef(false); diff --git a/example/src/demos/QuickStart.tsx b/example/src/demos/QuickStart.tsx index 4b658262..1b582847 100644 --- a/example/src/demos/QuickStart.tsx +++ b/example/src/demos/QuickStart.tsx @@ -13,7 +13,7 @@ import { useRiveFile, useRiveNumber, useRiveTrigger, - useViewModelInstance, + useViewModelInstanceAsync, Fit, } from '@rive-app/react-native'; import type { Metadata } from '../shared/metadata'; @@ -23,7 +23,7 @@ export default function QuickStart() { require('../../assets/rive/quick_start.riv') ); const { riveViewRef, setHybridRef } = useRive(); - const { instance: viewModelInstance } = useViewModelInstance(riveFile, { + const { instance: viewModelInstance } = useViewModelInstanceAsync(riveFile, { onInit: (vmi) => vmi.numberProperty('health')!.set(9), }); diff --git a/example/src/exercisers/FontFallbackExample.tsx b/example/src/exercisers/FontFallbackExample.tsx index 931593d6..d2eb5a80 100644 --- a/example/src/exercisers/FontFallbackExample.tsx +++ b/example/src/exercisers/FontFallbackExample.tsx @@ -15,7 +15,7 @@ import { useRive, useRiveFile, useRiveString, - useViewModelInstance, + useViewModelInstanceAsync, type FontSource, type FallbackFont, } from '@rive-app/react-native'; @@ -258,7 +258,7 @@ function MountedView({ text }: { text: string }) { // https://rive.app/marketplace/26480-49641-simple-test-text-property/ require('../../assets/rive/font_fallback.riv') ); - const { instance } = useViewModelInstance(riveFile); + const { instance } = useViewModelInstanceAsync(riveFile); const { setValue: setRiveText, error: textError } = useRiveString( TEXT_PROPERTY, diff --git a/example/src/exercisers/MenuListExample.tsx b/example/src/exercisers/MenuListExample.tsx index d81ce97c..2f437ad1 100644 --- a/example/src/exercisers/MenuListExample.tsx +++ b/example/src/exercisers/MenuListExample.tsx @@ -16,7 +16,7 @@ import { type RiveFile, useRiveFile, useRiveList, - useViewModelInstance, + useViewModelInstanceAsync, } from '@rive-app/react-native'; import { type Metadata } from '../shared/metadata'; @@ -41,7 +41,7 @@ export default function MenuListExample() { } function MenuList({ file }: { file: RiveFile }) { - const { instance, error } = useViewModelInstance(file); + const { instance, error } = useViewModelInstanceAsync(file); if (error) { console.error(error.message); diff --git a/example/src/exercisers/NestedViewModelExample.tsx b/example/src/exercisers/NestedViewModelExample.tsx index 0f0e39d1..60cc3cba 100644 --- a/example/src/exercisers/NestedViewModelExample.tsx +++ b/example/src/exercisers/NestedViewModelExample.tsx @@ -13,7 +13,7 @@ import { RiveView, useRiveFile, useRiveString, - useViewModelInstance, + useViewModelInstanceAsync, type ViewModelInstance, type RiveFile, type RiveViewRef, @@ -41,7 +41,7 @@ export default function NestedViewModelExample() { } function WithViewModelSetup({ file }: { file: RiveFile }) { - const { instance, error } = useViewModelInstance(file); + const { instance, error } = useViewModelInstanceAsync(file); if (error) { console.error(error.message); diff --git a/example/src/exercisers/RiveDataBindingExample.tsx b/example/src/exercisers/RiveDataBindingExample.tsx index 825c81f7..7b5a9aa7 100644 --- a/example/src/exercisers/RiveDataBindingExample.tsx +++ b/example/src/exercisers/RiveDataBindingExample.tsx @@ -4,7 +4,7 @@ import { Fit, RiveView, useRiveNumber, - useViewModelInstance, + useViewModelInstanceAsync, type ViewModelInstance, type RiveFile, useRiveString, @@ -37,7 +37,7 @@ export default function WithRiveFile() { } function WithViewModelSetup({ file }: { file: RiveFile }) { - const { instance, error } = useViewModelInstance(file); + const { instance, error } = useViewModelInstanceAsync(file); if (error) { console.error(error.message); diff --git a/example/src/reproducers/Issue297ThreadRace.tsx b/example/src/reproducers/Issue297ThreadRace.tsx index 10af8f40..1699a124 100644 --- a/example/src/reproducers/Issue297ThreadRace.tsx +++ b/example/src/reproducers/Issue297ThreadRace.tsx @@ -11,7 +11,7 @@ import { Fit, RiveView, useRiveFile, - useViewModelInstance, + useViewModelInstanceAsync, type ViewModelInstance, type RiveFile, } from '@rive-app/react-native'; @@ -130,7 +130,7 @@ function StressRunner({ } function WithViewModelSetup({ file }: { file: RiveFile }) { - const { instance, error } = useViewModelInstance(file); + const { instance, error } = useViewModelInstanceAsync(file); if (error) { return {error.message}; diff --git a/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx b/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx new file mode 100644 index 00000000..b92476f2 --- /dev/null +++ b/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx @@ -0,0 +1,382 @@ +import React from 'react'; +import { + renderHook, + render, + waitFor, + act, +} from '@testing-library/react-native'; +import { useViewModelInstanceAsync } from '../useViewModelInstanceAsync'; +import type { RiveFile } from '../../specs/RiveFile.nitro'; +import type { ViewModel, ViewModelInstance } from '../../specs/ViewModel.nitro'; +import type { ArtboardBy } from '../../specs/ArtboardBy'; + +function createMockViewModelInstance(name = 'TestInstance'): ViewModelInstance { + return { + instanceName: name, + dispose: jest.fn(), + numberProperty: jest.fn(), + stringProperty: jest.fn(), + booleanProperty: jest.fn(), + colorProperty: jest.fn(), + enumProperty: jest.fn(), + triggerProperty: jest.fn(), + imageProperty: jest.fn(), + listProperty: jest.fn(), + artboardProperty: jest.fn(), + viewModelAsync: jest.fn(), + replaceViewModel: jest.fn(), + } as any; +} + +function createMockViewModel(options?: { + defaultInstance?: ViewModelInstance; + namedInstances?: Record; + blankInstance?: ViewModelInstance; +}): ViewModel { + return { + modelName: 'TestViewModel', + dispose: jest.fn(), + createInstanceByNameAsync: jest.fn( + async (name: string) => options?.namedInstances?.[name] + ), + createDefaultInstanceAsync: jest.fn(async () => options?.defaultInstance), + createBlankInstanceAsync: jest.fn(async () => options?.blankInstance), + getPropertiesAsync: jest.fn(), + getPropertyCountAsync: jest.fn(), + getInstanceCountAsync: jest.fn(), + } as any; +} + +function createMockRiveFile(options?: { + defaultViewModel?: ViewModel; + artboardViewModels?: Record; + namedViewModels?: Record; +}): RiveFile { + return { + dispose: jest.fn(), + getBindableArtboard: jest.fn(), + viewModelByNameAsync: jest.fn( + async (name: string) => options?.namedViewModels?.[name] + ), + defaultArtboardViewModelAsync: jest.fn(async (artboardBy?: ArtboardBy) => { + if (artboardBy?.name && options?.artboardViewModels) { + return options.artboardViewModels[artboardBy.name]; + } + return options?.defaultViewModel; + }), + } as any; +} + +describe('useViewModelInstanceAsync - RiveFile source', () => { + it('is loading on first render, then resolves the default instance', async () => { + const defaultInstance = createMockViewModelInstance(); + const defaultViewModel = createMockViewModel({ defaultInstance }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile) + ); + + expect(result.current.isLoading).toBe(true); + expect(result.current.instance).toBeUndefined(); + expect(result.current.error).toBeNull(); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.instance).toBe(defaultInstance); + expect(result.current.error).toBeNull(); + expect(defaultViewModel.createDefaultInstanceAsync).toHaveBeenCalled(); + }); + + it('resolves a named instance via createInstanceByNameAsync', async () => { + const personInstance = createMockViewModelInstance('Person'); + const defaultViewModel = createMockViewModel({ + namedInstances: { PersonInstance: personInstance }, + }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile, { + instanceName: 'PersonInstance', + }) + ); + + await waitFor(() => expect(result.current.instance).toBe(personInstance)); + expect(defaultViewModel.createInstanceByNameAsync).toHaveBeenCalledWith( + 'PersonInstance' + ); + expect(result.current.error).toBeNull(); + }); + + it('resolves the ViewModel for a specific artboard', async () => { + const mainInstance = createMockViewModelInstance('Main'); + const mainArtboardViewModel = createMockViewModel({ + defaultInstance: mainInstance, + }); + const mockRiveFile = createMockRiveFile({ + artboardViewModels: { MainArtboard: mainArtboardViewModel }, + }); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile, { artboardName: 'MainArtboard' }) + ); + + await waitFor(() => expect(result.current.instance).toBe(mainInstance)); + expect(mockRiveFile.defaultArtboardViewModelAsync).toHaveBeenCalledWith({ + type: 'name', + name: 'MainArtboard', + }); + }); + + it('resolves a ViewModel by name', async () => { + const settingsInstance = createMockViewModelInstance('Settings'); + const settingsViewModel = createMockViewModel({ + defaultInstance: settingsInstance, + }); + const mockRiveFile = createMockRiveFile({ + namedViewModels: { Settings: settingsViewModel }, + }); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile, { viewModelName: 'Settings' }) + ); + + await waitFor(() => expect(result.current.instance).toBe(settingsInstance)); + expect(mockRiveFile.viewModelByNameAsync).toHaveBeenCalledWith('Settings'); + expect(mockRiveFile.defaultArtboardViewModelAsync).not.toHaveBeenCalled(); + }); + + it('sets an error when the instance name is not found (not required)', async () => { + const defaultViewModel = createMockViewModel({ namedInstances: {} }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile, { instanceName: 'NonExistent' }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.instance).toBeNull(); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toContain('NonExistent'); + }); + + it('resolves null with no error when the artboard has no ViewModel', async () => { + const mockRiveFile = createMockRiveFile({}); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.instance).toBeNull(); + expect(result.current.error).toBeNull(); + }); +}); + +describe('useViewModelInstanceAsync - ViewModel source', () => { + it('uses createDefaultInstanceAsync by default', async () => { + const defaultInstance = createMockViewModelInstance(); + const mockViewModel = createMockViewModel({ defaultInstance }); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockViewModel) + ); + + await waitFor(() => expect(result.current.instance).toBe(defaultInstance)); + expect(mockViewModel.createDefaultInstanceAsync).toHaveBeenCalled(); + }); + + it('uses createBlankInstanceAsync when useNew is true', async () => { + const blankInstance = createMockViewModelInstance('Blank'); + const mockViewModel = createMockViewModel({ blankInstance }); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockViewModel, { useNew: true }) + ); + + await waitFor(() => expect(result.current.instance).toBe(blankInstance)); + expect(mockViewModel.createBlankInstanceAsync).toHaveBeenCalled(); + expect(mockViewModel.createDefaultInstanceAsync).not.toHaveBeenCalled(); + }); + + it('uses createInstanceByNameAsync when name is provided', async () => { + const namedInstance = createMockViewModelInstance('Gordon'); + const mockViewModel = createMockViewModel({ + namedInstances: { Gordon: namedInstance }, + }); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockViewModel, { name: 'Gordon' }) + ); + + await waitFor(() => expect(result.current.instance).toBe(namedInstance)); + expect(mockViewModel.createInstanceByNameAsync).toHaveBeenCalledWith( + 'Gordon' + ); + }); +}); + +describe('useViewModelInstanceAsync - null source', () => { + it('stays in the loading state when the source is null', async () => { + const { result } = renderHook(() => useViewModelInstanceAsync(null)); + + expect(result.current.isLoading).toBe(true); + expect(result.current.instance).toBeUndefined(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.instance).toBeUndefined(); + }); +}); + +describe('useViewModelInstanceAsync - onInit', () => { + it('calls onInit with the resolved instance', async () => { + const defaultInstance = createMockViewModelInstance(); + const defaultViewModel = createMockViewModel({ defaultInstance }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + const onInit = jest.fn(); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile, { onInit }) + ); + + await waitFor(() => expect(result.current.instance).toBe(defaultInstance)); + expect(onInit).toHaveBeenCalledWith(defaultInstance); + expect(onInit).toHaveBeenCalledTimes(1); + }); + + it('surfaces an onInit throw as the error result', async () => { + const defaultInstance = createMockViewModelInstance(); + const defaultViewModel = createMockViewModel({ defaultInstance }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile, { + onInit: () => { + throw new Error('init boom'); + }, + }) + ); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(result.current.error?.message).toBe('init boom'); + expect(result.current.instance).toBeNull(); + expect(defaultInstance.dispose).toHaveBeenCalled(); + }); +}); + +describe('useViewModelInstanceAsync - disposal', () => { + it('disposes the instance on unmount', async () => { + const defaultInstance = createMockViewModelInstance(); + const defaultViewModel = createMockViewModel({ defaultInstance }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result, unmount } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile) + ); + + await waitFor(() => expect(result.current.instance).toBe(defaultInstance)); + unmount(); + expect(defaultInstance.dispose).toHaveBeenCalled(); + }); + + it('disposes a late-resolving instance when unmounted before resolution', async () => { + const defaultInstance = createMockViewModelInstance(); + const defaultViewModel = createMockViewModel(); + let resolveCreate: (v: ViewModelInstance) => void = () => {}; + (defaultViewModel.createDefaultInstanceAsync as jest.Mock).mockReturnValue( + new Promise((resolve) => { + resolveCreate = resolve; + }) + ); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { unmount } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile) + ); + + unmount(); + + await act(async () => { + resolveCreate(defaultInstance); + await Promise.resolve(); + }); + + expect(defaultInstance.dispose).toHaveBeenCalled(); + }); + + it('disposes the previous instance when the source changes', async () => { + const instanceA = createMockViewModelInstance('A'); + const fileA = createMockRiveFile({ + defaultViewModel: createMockViewModel({ defaultInstance: instanceA }), + }); + const instanceB = createMockViewModelInstance('B'); + const fileB = createMockRiveFile({ + defaultViewModel: createMockViewModel({ defaultInstance: instanceB }), + }); + + const { result, rerender } = renderHook( + ({ file }: { file: RiveFile }) => useViewModelInstanceAsync(file), + { initialProps: { file: fileA } } + ); + + await waitFor(() => expect(result.current.instance).toBe(instanceA)); + + await act(async () => { + rerender({ file: fileB }); + await Promise.resolve(); + }); + + await waitFor(() => expect(result.current.instance).toBe(instanceB)); + expect(instanceA.dispose).toHaveBeenCalled(); + }); +}); + +describe('useViewModelInstanceAsync - required', () => { + class ErrorBoundary extends React.Component< + { onError: (e: Error) => void; children?: React.ReactNode }, + { hasError: boolean } + > { + state = { hasError: false }; + static getDerivedStateFromError() { + return { hasError: true }; + } + componentDidCatch(error: Error) { + this.props.onError(error); + } + render() { + return this.state.hasError ? null : this.props.children; + } + } + + function Probe({ source }: { source: RiveFile }) { + useViewModelInstanceAsync(source, { + viewModelName: 'NonExistent', + required: true, + }); + return null; + } + + it('throws (caught by an Error Boundary) once it resolves to null', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const mockRiveFile = createMockRiveFile({ namedViewModels: {} }); + const onError = jest.fn(); + + render( + + + + ); + + await waitFor(() => expect(onError).toHaveBeenCalled()); + expect((onError.mock.calls[0][0] as Error).message).toContain( + 'NonExistent' + ); + consoleError.mockRestore(); + }); +}); diff --git a/src/hooks/useViewModelInstance.ts b/src/hooks/useViewModelInstance.ts index 3dc655a6..6d9257d8 100644 --- a/src/hooks/useViewModelInstance.ts +++ b/src/hooks/useViewModelInstance.ts @@ -201,6 +201,10 @@ export type UseViewModelInstanceResult = /** * Hook for getting a ViewModelInstance from a RiveFile, ViewModel, or RiveViewRef. * + * @deprecated Use {@link useViewModelInstanceAsync} instead. This hook creates the + * instance synchronously via deprecated runtime APIs that access the Rive runtime on + * the JS thread; the async variant uses the non-deprecated `*Async` APIs. + * * @param source - The RiveFile, ViewModel, or RiveViewRef to get an instance from * @param params - Configuration for which instance to retrieve * @returns An object with `instance` and `error` (discriminated union) diff --git a/src/hooks/useViewModelInstanceAsync.ts b/src/hooks/useViewModelInstanceAsync.ts new file mode 100644 index 00000000..b5219037 --- /dev/null +++ b/src/hooks/useViewModelInstanceAsync.ts @@ -0,0 +1,341 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ViewModel, ViewModelInstance } from '../specs/ViewModel.nitro'; +import type { RiveFile } from '../specs/RiveFile.nitro'; +import type { RiveViewRef } from '../index'; +import { callDispose } from '../core/callDispose'; +import { ArtboardByName } from '../specs/ArtboardBy'; +import type { + UseViewModelInstanceFileParams, + UseViewModelInstanceViewModelParams, + UseViewModelInstanceRefParams, +} from './useViewModelInstance'; + +type ViewModelSource = ViewModel | RiveFile | RiveViewRef; + +function isRiveViewRef( + source: ViewModelSource | null | undefined +): source is RiveViewRef { + return source != null && 'getViewModelInstance' in source; +} + +function isRiveFile( + source: ViewModelSource | null | undefined +): source is RiveFile { + return source != null && 'defaultArtboardViewModelAsync' in source; +} + +type CreateInstanceResult = { + instance: ViewModelInstance | null | undefined; + needsDispose: boolean; + error?: string; +}; + +async function createInstanceAsync( + source: ViewModelSource | null | undefined, + instanceName: string | undefined, + artboardName: string | undefined, + viewModelName: string | undefined, + useNew: boolean +): Promise { + if (!source) { + return { instance: undefined, needsDispose: false }; + } + + if (isRiveViewRef(source)) { + const vmi = source.getViewModelInstance(); + return { instance: vmi ?? null, needsDispose: false }; + } + + if (isRiveFile(source)) { + let viewModel: ViewModel | undefined; + if (viewModelName) { + viewModel = await source.viewModelByNameAsync(viewModelName); + if (!viewModel) { + return { + instance: null, + needsDispose: false, + error: `ViewModel '${viewModelName}' not found`, + }; + } + } else { + viewModel = await source.defaultArtboardViewModelAsync( + artboardName ? ArtboardByName(artboardName) : undefined + ); + if (!viewModel) { + if (artboardName) { + return { + instance: null, + needsDispose: false, + error: `Artboard '${artboardName}' not found or has no ViewModel`, + }; + } + return { instance: null, needsDispose: false }; + } + } + let vmi: ViewModelInstance | undefined; + if (instanceName) { + try { + vmi = await viewModel.createInstanceByNameAsync(instanceName); + } catch (e) { + console.warn(`createInstanceByNameAsync('${instanceName}') failed:`, e); + } + } else { + vmi = await viewModel.createDefaultInstanceAsync(); + } + if (!vmi && instanceName) { + return { + instance: null, + needsDispose: false, + error: `ViewModel instance '${instanceName}' not found`, + }; + } + return { instance: vmi ?? null, needsDispose: true }; + } + + // ViewModel source + let vmi: ViewModelInstance | undefined; + if (instanceName) { + try { + vmi = await source.createInstanceByNameAsync(instanceName); + } catch (e) { + console.warn(`createInstanceByNameAsync('${instanceName}') failed:`, e); + } + if (!vmi) { + return { + instance: null, + needsDispose: false, + error: `ViewModel instance '${instanceName}' not found`, + }; + } + } else if (useNew) { + vmi = await source.createBlankInstanceAsync(); + } else { + vmi = await source.createDefaultInstanceAsync(); + } + return { instance: vmi ?? null, needsDispose: true }; +} + +export type UseViewModelInstanceAsyncResult = + | { instance: ViewModelInstance; isLoading: false; error: null } + | { instance: null; isLoading: false; error: Error } + | { instance: null; isLoading: false; error: null } + | { instance: undefined; isLoading: true; error: null }; + +/** + * Result of {@link useViewModelInstanceAsync} when `required: true` is set. + * The `null` (error/absent) case is removed — instead the hook throws once the + * instance resolves to `null`, leaving only the ready and loading states. + */ +type UseViewModelInstanceAsyncRequiredResult = + | { instance: ViewModelInstance; isLoading: false; error: null } + | { instance: undefined; isLoading: true; error: null }; + +const LOADING_RESULT: UseViewModelInstanceAsyncResult = { + instance: undefined, + isLoading: true, + error: null, +}; + +/** + * Async version of {@link useViewModelInstance}. Creates a ViewModelInstance + * using the non-deprecated `*Async` runtime APIs, resolving off the JS thread. + * + * Because creation is asynchronous, the instance is not available on the first + * render. Consumers should guard on the result: + * + * ```tsx + * const { instance, isLoading, error } = useViewModelInstanceAsync(riveFile); + * if (isLoading || !instance) return ; + * // ... + * + * ``` + * + * @param source - The RiveFile, ViewModel, or RiveViewRef to get an instance from + * @param params - Configuration for which instance to retrieve + * @returns An object with `instance`, `isLoading`, and `error` (discriminated union) + * + * @example + * ```tsx + * // From RiveFile (get default instance) + * const { riveFile } = useRiveFile(require('./animation.riv')); + * const { instance, isLoading } = useViewModelInstanceAsync(riveFile); + * ``` + * + * @example + * ```tsx + * // From RiveFile with specific instance name + * const { instance } = useViewModelInstanceAsync(riveFile, { instanceName: 'PersonInstance' }); + * ``` + * + * @example + * ```tsx + * // From RiveFile with specific ViewModel name + * const { instance } = useViewModelInstanceAsync(riveFile, { viewModelName: 'Settings' }); + * ``` + * + * @example + * ```tsx + * // Create a new blank instance from ViewModel + * const viewModel = await file.viewModelByNameAsync('TodoItem'); + * const { instance } = useViewModelInstanceAsync(viewModel, { useNew: true }); + * ``` + * + * @example + * ```tsx + * // With required: true (throws once resolved to null, use with Error Boundary). + * // Note: instance is still `undefined` while loading — guard on isLoading. + * const { instance, isLoading } = useViewModelInstanceAsync(riveFile, { required: true }); + * ``` + * + * @example + * ```tsx + * // With onInit to set initial values before the instance is exposed or bound + * const { instance } = useViewModelInstanceAsync(riveFile, { + * onInit: (vmi) => { + * vmi.numberProperty('count')?.set(initialCount); + * } + * }); + * ``` + */ +// RiveFile overloads +export function useViewModelInstanceAsync( + source: RiveFile, + params: UseViewModelInstanceFileParams & { required: true } +): UseViewModelInstanceAsyncRequiredResult; +export function useViewModelInstanceAsync( + source: RiveFile | null | undefined, + params?: UseViewModelInstanceFileParams +): UseViewModelInstanceAsyncResult; + +// ViewModel overloads +export function useViewModelInstanceAsync( + source: ViewModel, + params: UseViewModelInstanceViewModelParams & { required: true } +): UseViewModelInstanceAsyncRequiredResult; +export function useViewModelInstanceAsync( + source: ViewModel | null | undefined, + params?: UseViewModelInstanceViewModelParams +): UseViewModelInstanceAsyncResult; + +// RiveViewRef overloads +export function useViewModelInstanceAsync( + source: RiveViewRef, + params: UseViewModelInstanceRefParams & { required: true } +): UseViewModelInstanceAsyncRequiredResult; +export function useViewModelInstanceAsync( + source: RiveViewRef | null | undefined, + params?: UseViewModelInstanceRefParams +): UseViewModelInstanceAsyncResult; + +// Implementation +export function useViewModelInstanceAsync( + source: ViewModelSource | null | undefined, + params?: + | UseViewModelInstanceFileParams + | UseViewModelInstanceViewModelParams + | UseViewModelInstanceRefParams +): UseViewModelInstanceAsyncResult { + const fileInstanceName = (params as { instanceName?: string } | undefined) + ?.instanceName; + const viewModelInstanceName = (params as { name?: string } | undefined)?.name; + const instanceName = fileInstanceName ?? viewModelInstanceName; + const artboardName = (params as UseViewModelInstanceFileParams | undefined) + ?.artboardName; + const viewModelName = (params as UseViewModelInstanceFileParams | undefined) + ?.viewModelName; + const useNew = + (params as UseViewModelInstanceViewModelParams | undefined)?.useNew ?? + false; + const required = params?.required ?? false; + const onInit = params?.onInit; + + const onInitRef = useRef(onInit); + onInitRef.current = onInit; + + const [result, setResult] = + useState(LOADING_RESULT); + + useEffect(() => { + // Reset to the loading state whenever the inputs change so we never expose a + // stale (and about-to-be-disposed) instance from a previous resolution. + setResult((prev) => (prev.isLoading ? prev : LOADING_RESULT)); + + if (!source) { + return; + } + + let cancelled = false; + let created: CreateInstanceResult | null = null; + + (async () => { + try { + const c = await createInstanceAsync( + source, + instanceName, + artboardName, + viewModelName, + useNew + ); + created = c; + + if (cancelled) { + if (c.needsDispose && c.instance) callDispose(c.instance); + return; + } + + if (c.instance) { + try { + onInitRef.current?.(c.instance); + } catch (e) { + created = null; + if (c.needsDispose) callDispose(c.instance); + setResult({ + instance: null, + isLoading: false, + error: e instanceof Error ? e : new Error(String(e)), + }); + return; + } + setResult({ instance: c.instance, isLoading: false, error: null }); + } else if (c.error) { + setResult({ + instance: null, + isLoading: false, + error: new Error(c.error), + }); + } else { + // Resolved, but there is genuinely no ViewModel (not an error). + setResult({ instance: null, isLoading: false, error: null }); + } + } catch (e) { + if (cancelled) return; + setResult({ + instance: null, + isLoading: false, + error: + e instanceof Error + ? e + : new Error('Failed to create ViewModel instance'), + }); + } + })(); + + return () => { + cancelled = true; + if (created?.needsDispose && created.instance) { + callDispose(created.instance); + } + }; + }, [source, instanceName, artboardName, viewModelName, useNew]); + + if (required && result.instance === null && !result.isLoading) { + throw new Error( + result.error + ? `useViewModelInstanceAsync: ${result.error.message}` + : 'useViewModelInstanceAsync: Failed to get ViewModelInstance. ' + + 'Ensure the source has a valid ViewModel and instance available.' + ); + } + + return result; +} diff --git a/src/index.tsx b/src/index.tsx index edcc5535..9280fa52 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -65,9 +65,14 @@ export { useRiveColor } from './hooks/useRiveColor'; export { useRiveTrigger } from './hooks/useRiveTrigger'; export { useRiveList } from './hooks/useRiveList'; export { + // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentionally re-exported for backward compatibility; prefer useViewModelInstanceAsync useViewModelInstance, type UseViewModelInstanceResult, } from './hooks/useViewModelInstance'; +export { + useViewModelInstanceAsync, + type UseViewModelInstanceAsyncResult, +} from './hooks/useViewModelInstanceAsync'; export { useRiveFile, type UseRiveFileResult } from './hooks/useRiveFile'; export { type RiveFileInput } from './hooks/useRiveFile'; export { type SetValueAction } from './types'; From e0d70e376de45c5edb88673dc204bc186e29c195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 1 Jul 2026 10:24:34 +0200 Subject: [PATCH 2/7] =?UTF-8?q?test(harness):=20fix=20flaky=20reconfigure?= =?UTF-8?q?=20test=20=E2=80=94=20wait=20for=20ViewModel=20property,=20not?= =?UTF-8?q?=20just=20the=20view=20ref?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the experimental iOS backend the ViewModel instance and its properties resolve asynchronously a short time after the view's hybridRef is assigned. The test read `getViewModelInstance().numberProperty('ypos')` immediately after waiting only for the ref, so on slower CI runners the property was occasionally still undefined, failing at `expect(ypos1).toBeDefined()` (~1 in 4 runs). Reproduced deterministically: at ref-callback time the VMI is null 40/40, becoming ready shortly after. Poll with waitFor until the `ypos` property is available before sampling it, for both the initial mount and the post-switch reconfigure. --- example/__tests__/reconfigure.harness.tsx | 32 ++++++++++++++++------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/example/__tests__/reconfigure.harness.tsx b/example/__tests__/reconfigure.harness.tsx index 131dfbf8..5759d24d 100644 --- a/example/__tests__/reconfigure.harness.tsx +++ b/example/__tests__/reconfigure.harness.tsx @@ -14,6 +14,7 @@ import { Fit, type RiveFile, type RiveViewRef, + type ViewModelNumberProperty, } from '@rive-app/react-native'; const BOUNCING_BALL = require('../assets/rive/bouncing_ball.riv'); @@ -74,11 +75,17 @@ describe('RiveView reconfigure (file switch)', () => { await render(); await waitFor(() => expect(context.ref).not.toBeNull(), { timeout: 5000 }); - // Confirm bouncing_ball is animating via its ypos ViewModel property - const vmi1 = context.ref!.getViewModelInstance(); - expect(vmi1).not.toBeNull(); - const ypos1 = vmi1!.numberProperty('ypos'); - expect(ypos1).toBeDefined(); + // The ViewModel instance and its properties resolve asynchronously a short + // time after the view ref is assigned, so poll until the property is + // available rather than reading it the instant the ref exists. + let ypos1: ViewModelNumberProperty | undefined; + await waitFor( + () => { + ypos1 = context.ref!.getViewModelInstance()?.numberProperty('ypos'); + expect(ypos1).toBeDefined(); + }, + { timeout: 5000 } + ); const valueBefore = ypos1!.value; await delay(500); expect(ypos1!.value).not.toBe(valueBefore); @@ -93,11 +100,16 @@ describe('RiveView reconfigure (file switch)', () => { await delay(600); expect(context.error).toBeNull(); - // Animation should still be running on the reconfigured view - const vmi2 = context.ref!.getViewModelInstance(); - expect(vmi2).not.toBeNull(); - const ypos2 = vmi2!.numberProperty('ypos'); - expect(ypos2).toBeDefined(); + // Animation should still be running on the reconfigured view. The + // reconfigured instance also resolves asynchronously, so poll for it too. + let ypos2: ViewModelNumberProperty | undefined; + await waitFor( + () => { + ypos2 = context.ref!.getViewModelInstance()?.numberProperty('ypos'); + expect(ypos2).toBeDefined(); + }, + { timeout: 5000 } + ); const valueAfterSwitch = ypos2!.value; await delay(500); expect(ypos2!.value).not.toBe(valueAfterSwitch); From ef44754f8eccffebcf76a015cc5652501dd6d4f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 3 Jul 2026 14:43:07 +0200 Subject: [PATCH 3/7] =?UTF-8?q?test(harness):=20fix=20flaky=20autoplay=20a?= =?UTF-8?q?uto-dataBind=20test=20=E2=80=94=20wait=20for=20ViewModel=20prop?= =?UTF-8?q?erty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same race as 53b4aa0 (reconfigure test): the default ViewModel instance and its properties resolve asynchronously a short time after the view's hybridRef is assigned. The test read getViewModelInstance().numberProperty('ypos') right after waiting only for the ref, so the VMI was intermittently still undefined — `expect(vmi).not.toBeNull()` passed (undefined !== null) and the next line threw "Cannot read property 'numberProperty' of undefined". Poll with waitFor until the ypos property is available before sampling it. --- example/__tests__/autoplay.harness.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/example/__tests__/autoplay.harness.tsx b/example/__tests__/autoplay.harness.tsx index c90f2782..7ccae3b9 100644 --- a/example/__tests__/autoplay.harness.tsx +++ b/example/__tests__/autoplay.harness.tsx @@ -397,9 +397,17 @@ describe('Auto dataBind with no default ViewModel (issue #189)', () => { expect(context.error).toBeNull(); - const vmi = context.ref!.getViewModelInstance(); - expect(vmi).not.toBeNull(); - expect(vmi!.numberProperty('ypos')).toBeDefined(); + // The default ViewModel instance and its properties resolve asynchronously a + // short time after the view ref is assigned, so poll until the property is + // available rather than reading it the instant the ref exists. + await waitFor( + () => { + expect( + context.ref!.getViewModelInstance()?.numberProperty('ypos') + ).toBeDefined(); + }, + { timeout: 5000 } + ); cleanup(); }); From 5864c7fab546410ad786a9d552743235300ded84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 3 Jul 2026 14:48:03 +0200 Subject: [PATCH 4/7] ci(ios): capture simulator crash reports on iOS harness failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS harness job fails when the app crashes at launch (bridge never connects, 15-min timeout), but the existing debug step only dumps os_log — which comes back empty for an early-launch crash. Add a step that collects RiveExample crash reports (.ips/.crash) from the host and simulator DiagnosticReports dirs, prints them to the log, and uploads them as an artifact, so the actual crash stack is visible instead of an empty log. --- .github/workflows/ci.yml | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04455bbd..1d682edc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -416,6 +416,45 @@ jobs: echo "=== System log for Metro/Node ===" xcrun simctl spawn booted log show --predicate 'process CONTAINS "node" OR process CONTAINS "metro"' --last 5m --style compact 2>&1 | tail -50 || true + - name: Debug - Collect iOS crash reports + if: failure() || cancelled() + run: | + set +e + UDID=$(xcrun simctl list devices | awk -F '[()]' '/Booted/{print $2; exit}') + echo "Booted simulator UDID: ${UDID:-}" + mkdir -p crash-reports + # Simulator app crashes are written to the host's DiagnosticReports by + # ReportCrash, and sometimes inside the simulator's own data dir. Grab + # RiveExample-named reports plus anything from the last 25 min as a fallback. + for dir in \ + "$HOME/Library/Logs/DiagnosticReports" \ + "$HOME/Library/Developer/CoreSimulator/Devices/$UDID/data/Library/Logs/DiagnosticReports"; do + [ -d "$dir" ] || continue + find "$dir" -type f \ + \( -iname 'RiveExample-*' -o \( \( -iname '*.ips' -o -iname '*.crash' \) -mmin -25 \) \) \ + -exec cp {} crash-reports/ \; 2>/dev/null + done + count=$(ls -1 crash-reports 2>/dev/null | wc -l | tr -d ' ') + echo "=== Collected ${count} crash report(s) ===" + for f in crash-reports/*; do + [ -e "$f" ] || continue + echo "---------------- ${f} ----------------" + cat "$f" + echo + done + if [ "${count}" = "0" ]; then + echo "No crash reports found (app may not have crashed, or reports were not flushed yet)." + fi + exit 0 + + - name: Upload iOS crash reports + if: failure() || cancelled() + uses: actions/upload-artifact@v4 + with: + name: ios-harness-crash-reports + path: crash-reports/ + if-no-files-found: ignore + test-harness-android: runs-on: ubuntu-latest timeout-minutes: 30 From 238166dacf4f3da89fdc5794a3dd0e7eb750f246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 7 Jul 2026 10:38:10 +0200 Subject: [PATCH 5/7] fix(hooks): surface native failures from useViewModelInstanceAsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the review findings on the async hook (same issues as #305, with the design refined for this branch's native backends): - Map a rejected artboard lookup to the friendly "Artboard 'X' not found or has no ViewModel" error — both platforms throw on an unknown artboard name rather than resolving undefined. A rejection with no artboardName still propagates unchanged. - Keep the stable "ViewModel instance 'X' not found" message when createInstanceByNameAsync rejects, but attach the native error as cause instead of console.warn-ing it away. - Settle to a terminal { instance: null, isLoading: false } when the source is null (useRiveFile's error state) instead of loading forever; undefined still means loading, mirroring useRiveFile's convention. - QuickStart: surface useRiveFile/useViewModelInstanceAsync errors so a failing onInit no longer yields a silent blank screen. - Add an on-device e2e harness covering the real native rejection paths the Jest mocks can't. --- .../useViewModelInstanceAsync-e2e.harness.tsx | 341 ++++++++++++++++++ example/src/demos/QuickStart.tsx | 24 +- .../useViewModelInstanceAsync.test.tsx | 86 ++++- src/hooks/useViewModelInstanceAsync.ts | 81 ++++- 4 files changed, 510 insertions(+), 22 deletions(-) create mode 100644 example/__tests__/useViewModelInstanceAsync-e2e.harness.tsx diff --git a/example/__tests__/useViewModelInstanceAsync-e2e.harness.tsx b/example/__tests__/useViewModelInstanceAsync-e2e.harness.tsx new file mode 100644 index 00000000..ee7cc06d --- /dev/null +++ b/example/__tests__/useViewModelInstanceAsync-e2e.harness.tsx @@ -0,0 +1,341 @@ +import { + describe, + it, + expect, + render, + waitFor, + cleanup, +} from 'react-native-harness'; +import { useEffect } from 'react'; +import { Platform, Text, View } from 'react-native'; +import { + RiveFileFactory, + useRiveFile, + useViewModelInstanceAsync, + type RiveFile, + type RiveFileInput, + type ViewModelInstance, +} from '@rive-app/react-native'; + +// These run against the real native runtime on purpose: the Jest unit tests +// mock the *Async APIs so an unknown artboard/viewModel resolves `undefined`, +// but both platforms actually *throw* on a bad artboard name — the exact gap +// the friendly-error mapping in useViewModelInstanceAsync closes. +const MULTI_AB = require('../assets/rive/arbtboards-models-instances.riv'); + +function expectDefined(value: T): asserts value is NonNullable { + expect(value).toBeDefined(); +} + +async function loadFile() { + return RiveFileFactory.fromSource(MULTI_AB, undefined); +} + +type AsyncCtx = { + instance: ViewModelInstance | null | undefined; + error: Error | null; + isLoading: boolean; +}; + +function createCtx(): AsyncCtx { + return { instance: undefined, error: null, isLoading: true }; +} + +function ArtboardProbe({ + file, + artboardName, + ctx, +}: { + file: RiveFile; + artboardName: string; + ctx: AsyncCtx; +}) { + const { instance, error, isLoading } = useViewModelInstanceAsync(file, { + artboardName, + }); + useEffect(() => { + ctx.instance = instance; + ctx.error = error; + ctx.isLoading = isLoading; + }, [ctx, instance, error, isLoading]); + return ( + + {String(isLoading)} + + ); +} + +function ViewModelProbe({ + file, + viewModelName, + onInit, + ctx, +}: { + file: RiveFile; + viewModelName: string; + onInit?: (vmi: ViewModelInstance) => void; + ctx: AsyncCtx; +}) { + const { instance, error, isLoading } = useViewModelInstanceAsync(file, { + viewModelName, + onInit, + }); + useEffect(() => { + ctx.instance = instance; + ctx.error = error; + ctx.isLoading = isLoading; + }, [ctx, instance, error, isLoading]); + return ( + + {String(isLoading)} + + ); +} + +function InstanceNameProbe({ + file, + viewModelName, + instanceName, + ctx, +}: { + file: RiveFile; + viewModelName: string; + instanceName: string; + ctx: AsyncCtx; +}) { + const { instance, error, isLoading } = useViewModelInstanceAsync(file, { + viewModelName, + instanceName, + }); + useEffect(() => { + ctx.instance = instance; + ctx.error = error; + ctx.isLoading = isLoading; + }, [ctx, instance, error, isLoading]); + return ( + + {String(isLoading)} + + ); +} + +function FixedSourceProbe({ + source, + ctx, +}: { + source: RiveFile | null | undefined; + ctx: AsyncCtx; +}) { + const { instance, error, isLoading } = useViewModelInstanceAsync(source); + useEffect(() => { + ctx.instance = instance; + ctx.error = error; + ctx.isLoading = isLoading; + }, [ctx, instance, error, isLoading]); + return ( + + {String(isLoading)} + + ); +} + +type FileErrorCtx = AsyncCtx & { fileErrored: boolean }; + +function FileErrorProbe({ + input, + ctx, +}: { + input: RiveFileInput | undefined; + ctx: FileErrorCtx; +}) { + const { riveFile, error: fileError } = useRiveFile(input); + const { instance, error, isLoading } = useViewModelInstanceAsync(riveFile); + useEffect(() => { + ctx.fileErrored = fileError != null; + ctx.instance = instance; + ctx.error = error; + ctx.isLoading = isLoading; + }, [ctx, fileError, instance, error, isLoading]); + return ( + + {String(isLoading)} + + ); +} + +// ── #1: unknown artboard name maps to the friendly not-found error ─── +// Native throws (iOS `createArtboard`, Android `Artboard.fromFile`) rather than +// resolving undefined, so without the mapping this would leak the raw native +// message through the outer catch and the "not found" branch would be dead. + +describe('useViewModelInstanceAsync: unknown artboard name', () => { + it('surfaces the friendly "not found" error instead of the raw native message', async () => { + const file = await loadFile(); + const ctx = createCtx(); + await render( + + ); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 5000 }); + expect(ctx.instance).toBeNull(); + expectDefined(ctx.error); + // Exact match: the raw native rejection message differs, so this fails if + // the friendly-error mapping is removed and the native message leaks. + expect(ctx.error.message).toBe( + "Artboard 'doesNotExist' not found or has no ViewModel" + ); + cleanup(); + }); + + it('resolves an instance for a valid artboard name (control)', async () => { + const file = await loadFile(); + const ctx = createCtx(); + await render( + + ); + await waitFor(() => expect(ctx.instance).toBeTruthy(), { timeout: 5000 }); + expect(ctx.error).toBeNull(); + cleanup(); + }); +}); + +// ── #3: onInit failures are surfaced, not swallowed ────────────────── +// QuickStart gated its RiveView on `instance` alone and ignored `error`, so a +// throwing onInit silently blank-screened. These lock the contract the fixed +// example now relies on: a throw becomes `error`, a clean onInit resolves. + +describe('useViewModelInstanceAsync: onInit', () => { + it('surfaces an onInit throw as error and leaves instance null', async () => { + const file = await loadFile(); + const ctx = createCtx(); + await render( + { + throw new Error('init boom'); + }} + ctx={ctx} + /> + ); + await waitFor(() => expect(ctx.error).not.toBeNull(), { timeout: 5000 }); + expect(ctx.error!.message).toBe('init boom'); + expect(ctx.instance).toBeNull(); + cleanup(); + }); + + it('runs a clean onInit against the real instance and resolves (control)', async () => { + const file = await loadFile(); + const ctx = createCtx(); + let seenId: string | undefined; + await render( + { + seenId = vmi.stringProperty('_id')?.value; + }} + ctx={ctx} + /> + ); + await waitFor(() => expect(ctx.instance).toBeTruthy(), { timeout: 5000 }); + expect(ctx.error).toBeNull(); + expect(seenId).toBe('vm1.vmi.id'); + cleanup(); + }); +}); + +// ── #2: a null (errored/absent) source must not spin forever ───────── +// useRiveFile returns `undefined` while loading but `null` once it errors. The +// hook must keep `undefined` in the loading state (file still resolving) yet +// terminate on `null`, otherwise a consumer keying a spinner off `isLoading` +// hangs forever with no signal. + +describe('useViewModelInstanceAsync: null vs undefined source', () => { + it('stays loading while the source is undefined (file still resolving)', async () => { + const ctx = createCtx(); + await render(); + // Give any async resolution a chance to (incorrectly) settle. + await new Promise((r) => setTimeout(r, 500)); + expect(ctx.isLoading).toBe(true); + expect(ctx.instance).toBeUndefined(); + expect(ctx.error).toBeNull(); + cleanup(); + }); + + it('terminates (not loading) when the source is null', async () => { + const ctx = createCtx(); + await render(); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 3000 }); + expect(ctx.instance).toBeNull(); + expect(ctx.error).toBeNull(); + cleanup(); + }); + + it('terminates when useRiveFile fails to load the file', async () => { + const ctx: FileErrorCtx = { ...createCtx(), fileErrored: false }; + // `undefined` input makes useRiveFile resolve to { riveFile: null, error }, + // the same terminal shape a real load failure produces. + await render(); + await waitFor(() => expect(ctx.fileErrored).toBe(true), { timeout: 3000 }); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 3000 }); + expect(ctx.instance).toBeNull(); + cleanup(); + }); +}); + +// ── Instance creation failure keeps a clean message + native cause ─── +// When createInstanceByNameAsync *rejects*, the message stays a stable +// "… not found" but the native diagnostic is attached as `error.cause` so it +// isn't lost. The platforms diverge on a bad name: iOS throws +// `invalidViewModelInstance` (no pre-check) → a cause is present; Android's +// `contains()` guard resolves null → no cause. This pins that real contract. + +describe('useViewModelInstanceAsync: instance creation failure', () => { + it('keeps a clean not-found message and attaches the native cause on iOS', async () => { + const file = await loadFile(); + const ctx = createCtx(); + await render( + + ); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 5000 }); + expect(ctx.instance).toBeNull(); + expectDefined(ctx.error); + // Message stays clean and stable on every platform. + expect(ctx.error.message).toContain('not found'); + const cause = (ctx.error as Error & { cause?: unknown }).cause; + if (Platform.OS === 'ios') { + // iOS rejects with invalidViewModelInstance — preserved as `cause`, + // not leaked into the message. + expectDefined(cause); + expect(String((cause as Error).message)).toContain('Could not create'); + } else { + // Android resolves null on a missing name — a plain not-found, no cause. + expect(cause).toBeUndefined(); + } + cleanup(); + }); + + it('reports not-found when the instance genuinely resolves to null', async () => { + // A ViewModel with no such named instance that resolves (not throws) must + // still read as "not found" — the branch reserved for the null case. + const file = await loadFile(); + const ctx = createCtx(); + await render( + + ); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 5000 }); + expect(ctx.instance).toBeNull(); + expectDefined(ctx.error); + cleanup(); + }); +}); diff --git a/example/src/demos/QuickStart.tsx b/example/src/demos/QuickStart.tsx index 1b582847..62a82eb8 100644 --- a/example/src/demos/QuickStart.tsx +++ b/example/src/demos/QuickStart.tsx @@ -6,7 +6,7 @@ - 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, @@ -19,13 +19,16 @@ import { 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 } = useViewModelInstanceAsync(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,14 @@ export default function QuickStart() { riveViewRef!.play(); }; + if (fileError || error) { + return ( + + {(fileError ?? error)!.message} + + ); + } + return ( {riveFile && viewModelInstance && ( @@ -86,4 +97,7 @@ const styles = StyleSheet.create({ width: '100%', height: 400, }, + errorText: { + color: 'red', + }, }); diff --git a/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx b/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx index b92476f2..76744979 100644 --- a/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx +++ b/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx @@ -160,6 +160,45 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { expect(result.current.error?.message).toContain('NonExistent'); }); + it('attaches the native cause when createInstanceByNameAsync rejects', async () => { + // A rejection is a real creation failure — keep the clean "not found" + // message but preserve the native diagnostic as `error.cause`. + const nativeError = new Error( + 'invalidViewModelInstance("Could not create ...")' + ); + const defaultViewModel = createMockViewModel({}); + (defaultViewModel.createInstanceByNameAsync as jest.Mock).mockRejectedValue( + nativeError + ); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile, { instanceName: 'Whatever' }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.instance).toBeNull(); + expect(result.current.error?.message).toBe( + "ViewModel instance 'Whatever' not found" + ); + expect(result.current.error?.cause).toBe(nativeError); + }); + + it('has no cause when a missing instance resolves to null (not a rejection)', async () => { + const defaultViewModel = createMockViewModel({ namedInstances: {} }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile, { instanceName: 'Missing' }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error?.message).toBe( + "ViewModel instance 'Missing' not found" + ); + expect(result.current.error?.cause).toBeUndefined(); + }); + it('resolves null with no error when the artboard has no ViewModel', async () => { const mockRiveFile = createMockRiveFile({}); @@ -171,6 +210,41 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { expect(result.current.instance).toBeNull(); expect(result.current.error).toBeNull(); }); + + it('maps an artboard-lookup rejection to the not-found error', async () => { + // Both native platforms *throw* on an unknown artboard name rather than + // resolving undefined, so the rejection must map to the friendly message. + const mockRiveFile = createMockRiveFile({}); + (mockRiveFile.defaultArtboardViewModelAsync as jest.Mock).mockRejectedValue( + new Error('Artboard not found in file') + ); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile, { artboardName: '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).toBe( + "Artboard 'NonExistent' not found or has no ViewModel" + ); + }); + + it('propagates a rejection unchanged when no artboardName was given', async () => { + const mockRiveFile = createMockRiveFile({}); + (mockRiveFile.defaultArtboardViewModelAsync as jest.Mock).mockRejectedValue( + new Error('native boom') + ); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.instance).toBeNull(); + expect(result.current.error?.message).toBe('native boom'); + }); }); describe('useViewModelInstanceAsync - ViewModel source', () => { @@ -216,10 +290,18 @@ describe('useViewModelInstanceAsync - ViewModel source', () => { }); }); -describe('useViewModelInstanceAsync - null source', () => { - it('stays in the loading state when the source is null', async () => { +describe('useViewModelInstanceAsync - null vs undefined source', () => { + it('settles to a terminal null when the source is null (e.g. file load failed)', async () => { const { result } = renderHook(() => useViewModelInstanceAsync(null)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.instance).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('stays in the loading state when the source is undefined (still resolving)', async () => { + const { result } = renderHook(() => useViewModelInstanceAsync(undefined)); + expect(result.current.isLoading).toBe(true); expect(result.current.instance).toBeUndefined(); diff --git a/src/hooks/useViewModelInstanceAsync.ts b/src/hooks/useViewModelInstanceAsync.ts index b5219037..8bcf2d6a 100644 --- a/src/hooks/useViewModelInstanceAsync.ts +++ b/src/hooks/useViewModelInstanceAsync.ts @@ -27,9 +27,21 @@ function isRiveFile( type CreateInstanceResult = { instance: ViewModelInstance | null | undefined; needsDispose: boolean; - error?: string; + error?: Error; }; +// The message stays clean and stable ("… not found"); when the native call +// *rejected* (a real creation failure) the runtime error is attached as +// `cause` so its diagnostic (e.g. iOS reports which view model it came from) is +// preserved without leaking into the message. A plain resolve-without-instance +// (Android's missing-name path returns null) carries no cause. See #305. +function instanceNotFoundError(instanceName: string, cause?: unknown): Error { + return new Error( + `ViewModel instance '${instanceName}' not found`, + cause !== undefined ? { cause } : undefined + ); +} + async function createInstanceAsync( source: ViewModelSource | null | undefined, instanceName: string | undefined, @@ -54,19 +66,30 @@ async function createInstanceAsync( return { instance: null, needsDispose: false, - error: `ViewModel '${viewModelName}' not found`, + error: new Error(`ViewModel '${viewModelName}' not found`), }; } } else { - viewModel = await source.defaultArtboardViewModelAsync( - artboardName ? ArtboardByName(artboardName) : undefined - ); + try { + viewModel = await source.defaultArtboardViewModelAsync( + artboardName ? ArtboardByName(artboardName) : undefined + ); + } catch (e) { + // Both platforms *throw* on an unknown artboard name (iOS + // `createArtboard`, Android `Artboard.fromFile`) rather than resolving + // undefined, so map the rejection to the not-found error below instead + // of leaking the raw native message. Without a name it's a real error. + if (!artboardName) throw e; + viewModel = undefined; + } if (!viewModel) { if (artboardName) { return { instance: null, needsDispose: false, - error: `Artboard '${artboardName}' not found or has no ViewModel`, + error: new Error( + `Artboard '${artboardName}' not found or has no ViewModel` + ), }; } return { instance: null, needsDispose: false }; @@ -77,7 +100,11 @@ async function createInstanceAsync( try { vmi = await viewModel.createInstanceByNameAsync(instanceName); } catch (e) { - console.warn(`createInstanceByNameAsync('${instanceName}') failed:`, e); + return { + instance: null, + needsDispose: false, + error: instanceNotFoundError(instanceName, e), + }; } } else { vmi = await viewModel.createDefaultInstanceAsync(); @@ -86,7 +113,7 @@ async function createInstanceAsync( return { instance: null, needsDispose: false, - error: `ViewModel instance '${instanceName}' not found`, + error: instanceNotFoundError(instanceName), }; } return { instance: vmi ?? null, needsDispose: true }; @@ -98,13 +125,17 @@ async function createInstanceAsync( try { vmi = await source.createInstanceByNameAsync(instanceName); } catch (e) { - console.warn(`createInstanceByNameAsync('${instanceName}') failed:`, e); + return { + instance: null, + needsDispose: false, + error: instanceNotFoundError(instanceName, e), + }; } if (!vmi) { return { instance: null, needsDispose: false, - error: `ViewModel instance '${instanceName}' not found`, + error: instanceNotFoundError(instanceName), }; } } else if (useNew) { @@ -150,6 +181,19 @@ const LOADING_RESULT: UseViewModelInstanceAsyncResult = { * * ``` * + * A `null` source resolves to a terminal `{ instance: null, isLoading: false }` + * (not perpetual loading), while an `undefined` source keeps the hook loading. + * This mirrors {@link useRiveFile}, which returns `riveFile: undefined` while + * loading and `riveFile: null` on error — so when chaining the two, check the + * file's own `error`, since this hook cannot observe why the source is absent: + * + * ```tsx + * const { riveFile, error: fileError } = useRiveFile(source); + * const { instance, isLoading } = useViewModelInstanceAsync(riveFile); + * if (fileError) return {fileError.message}; + * if (isLoading || !instance) return ; + * ``` + * * @param source - The RiveFile, ViewModel, or RiveViewRef to get an instance from * @param params - Configuration for which instance to retrieve * @returns An object with `instance`, `isLoading`, and `error` (discriminated union) @@ -256,11 +300,22 @@ export function useViewModelInstanceAsync( useState(LOADING_RESULT); useEffect(() => { + if (source === null) { + // Source resolved to absent/failed rather than pending. `useRiveFile` + // returns `riveFile: null` on load error (vs `undefined` while loading), + // so settle to a terminal null instead of spinning forever — otherwise a + // consumer keying a spinner off `isLoading` hangs with no signal. The + // file's own `error` carries the reason. + setResult({ instance: null, isLoading: false, error: null }); + return; + } + // 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) { + // `undefined`: not resolved yet (e.g. the file is still loading). return; } @@ -298,11 +353,7 @@ export function useViewModelInstanceAsync( } setResult({ instance: c.instance, isLoading: false, error: null }); } else if (c.error) { - setResult({ - instance: null, - isLoading: false, - error: new Error(c.error), - }); + setResult({ instance: null, isLoading: false, error: c.error }); } else { // Resolved, but there is genuinely no ViewModel (not an error). setResult({ instance: null, isLoading: false, error: null }); From 08f6c770c54246efd7e6f231f034544fe85f8f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 7 Jul 2026 12:07:58 +0200 Subject: [PATCH 6/7] fix: backend-divergence issues from the #304 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - e2e: assert the stable not-found message on every backend and treat error.cause as optional (present only when the backend rejects). The old iOS branch expected a cause unconditionally and failed on the legacy backend, which resolves nil instead of throwing (reproduced on a USE_RIVE_LEGACY build). - useRive: represent 'view not ready yet' as undefined and keep null for failure, matching the useRiveFile convention. Previously the initial null made useViewModelInstanceAsync(riveViewRef) settle to a terminal no-ViewModel state during normal mount, and required: true threw to the error boundary before the view attached. - ios/new + android/new: resolve null from defaultArtboardViewModel* when the artboard has no default ViewModel instead of letting getDefaultViewModelInfo's rejection surface as a raw error for a valid VM-less file (issue #189 fixture; reproduced on-device, new e2e test guards it). - comments: reword the categorical 'both platforms throw' claims — that only describes the new backend; legacy resolves nil/null and the not-found branches are its live path. Verified: jest 74 passed; full iOS harness on the new backend 196 passed; e2e file on the legacy backend 10 passed. --- .../com/margelo/nitro/rive/HybridRiveFile.kt | 10 ++- .../useViewModelInstanceAsync-e2e.harness.tsx | 62 ++++++++++++------- ios/new/HybridRiveFile.swift | 8 ++- src/hooks/__tests__/useRive.test.ts | 13 ++++ .../useViewModelInstanceAsync.test.tsx | 5 +- src/hooks/useRive.ts | 8 ++- src/hooks/useViewModelInstanceAsync.ts | 25 ++++---- 7 files changed, 93 insertions(+), 38 deletions(-) create mode 100644 src/hooks/__tests__/useRive.test.ts diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridRiveFile.kt b/android/src/new/java/com/margelo/nitro/rive/HybridRiveFile.kt index 05bc6836..5c1d27ee 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridRiveFile.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridRiveFile.kt @@ -99,7 +99,15 @@ class HybridRiveFile( Artboard.fromFile(file) } val vmSource = ViewModelSource.DefaultForArtboard(artboard) - val vmInfo = file.getDefaultViewModelInfo(artboard) + // getDefaultViewModelInfo throws when the artboard has no default + // ViewModel — a normal state for VM-less files (issue #189 fixture), not + // a failure. Resolve null like the legacy backend so callers get the + // documented "no ViewModel" result instead of a raw error. + val vmInfo = try { + file.getDefaultViewModelInfo(artboard) + } catch (e: Exception) { + return null + } return HybridViewModel(file, riveWorker, vmInfo.viewModelName, this, vmSource) } diff --git a/example/__tests__/useViewModelInstanceAsync-e2e.harness.tsx b/example/__tests__/useViewModelInstanceAsync-e2e.harness.tsx index ee7cc06d..660c7eea 100644 --- a/example/__tests__/useViewModelInstanceAsync-e2e.harness.tsx +++ b/example/__tests__/useViewModelInstanceAsync-e2e.harness.tsx @@ -7,7 +7,7 @@ import { cleanup, } from 'react-native-harness'; import { useEffect } from 'react'; -import { Platform, Text, View } from 'react-native'; +import { Text, View } from 'react-native'; import { RiveFileFactory, useRiveFile, @@ -18,10 +18,12 @@ import { } from '@rive-app/react-native'; // These run against the real native runtime on purpose: the Jest unit tests -// mock the *Async APIs so an unknown artboard/viewModel resolves `undefined`, -// but both platforms actually *throw* on a bad artboard name — the exact gap -// the friendly-error mapping in useViewModelInstanceAsync closes. +// mock the *Async APIs, but the backends genuinely diverge — the new backend +// *rejects* on a bad artboard/instance name while the legacy backend resolves +// null/undefined. These pin the hook's contract across both. const MULTI_AB = require('../assets/rive/arbtboards-models-instances.riv'); +// ViewModels exist but no artboard default (issue #189 fixture). +const NO_DEFAULT_VM = require('../assets/rive/nodefaultbouncing.riv'); function expectDefined(value: T): asserts value is NonNullable { expect(value).toBeDefined(); @@ -164,9 +166,9 @@ function FileErrorProbe({ } // ── #1: unknown artboard name maps to the friendly not-found error ─── -// Native throws (iOS `createArtboard`, Android `Artboard.fromFile`) rather than -// resolving undefined, so without the mapping this would leak the raw native -// message through the outer catch and the "not found" branch would be dead. +// The new backend throws (iOS `createArtboard`, Android `Artboard.fromFile`) +// while the legacy backend resolves undefined; the hook must map both to the +// same friendly error instead of leaking a raw native message. describe('useViewModelInstanceAsync: unknown artboard name', () => { it('surfaces the friendly "not found" error instead of the raw native message', async () => { @@ -284,14 +286,15 @@ describe('useViewModelInstanceAsync: null vs undefined source', () => { }); // ── Instance creation failure keeps a clean message + native cause ─── -// When createInstanceByNameAsync *rejects*, the message stays a stable -// "… not found" but the native diagnostic is attached as `error.cause` so it -// isn't lost. The platforms diverge on a bad name: iOS throws -// `invalidViewModelInstance` (no pre-check) → a cause is present; Android's -// `contains()` guard resolves null → no cause. This pins that real contract. +// The stable contract is the message: always "… not found", never a raw +// native error. `cause` is an optional diagnostic — present when the backend +// *rejects* (the new iOS backend throws `invalidViewModelInstance`), absent +// when the lookup resolves null (the legacy backends, and Android's +// `contains()` pre-check). Asserting a cause per-platform would pin that +// accidental divergence, so only its shape is checked when present. describe('useViewModelInstanceAsync: instance creation failure', () => { - it('keeps a clean not-found message and attaches the native cause on iOS', async () => { + it('keeps a clean not-found message on every backend (cause optional)', async () => { const file = await loadFile(); const ctx = createCtx(); await render( @@ -305,17 +308,13 @@ describe('useViewModelInstanceAsync: instance creation failure', () => { await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 5000 }); expect(ctx.instance).toBeNull(); expectDefined(ctx.error); - // Message stays clean and stable on every platform. + // Message stays clean and stable on every platform and backend. expect(ctx.error.message).toContain('not found'); + expect(ctx.error.message).not.toContain('Could not create'); const cause = (ctx.error as Error & { cause?: unknown }).cause; - if (Platform.OS === 'ios') { - // iOS rejects with invalidViewModelInstance — preserved as `cause`, - // not leaked into the message. - expectDefined(cause); - expect(String((cause as Error).message)).toContain('Could not create'); - } else { - // Android resolves null on a missing name — a plain not-found, no cause. - expect(cause).toBeUndefined(); + if (cause !== undefined) { + // A rejecting backend must preserve the native diagnostic, not lose it. + expect(String((cause as Error).message).length).toBeGreaterThan(0); } cleanup(); }); @@ -339,3 +338,22 @@ describe('useViewModelInstanceAsync: instance creation failure', () => { cleanup(); }); }); + +// ── A VM-less default artboard is "no ViewModel", not an error ─────── +// The new backend's getDefaultViewModelInfo throws when the artboard has no +// default ViewModel, which would surface a raw native error for a perfectly +// valid file (the issue #189 fixture); the legacy backend resolves null. The +// natives normalize this to resolve-null so the hook's documented +// { instance: null, error: null } state is reachable on every backend. + +describe('useViewModelInstanceAsync: file without a default ViewModel', () => { + it('resolves null with no error', async () => { + const file = await RiveFileFactory.fromSource(NO_DEFAULT_VM, undefined); + const ctx = createCtx(); + await render(); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 5000 }); + expect(ctx.instance).toBeNull(); + expect(ctx.error).toBeNull(); + cleanup(); + }); +}); diff --git a/ios/new/HybridRiveFile.swift b/ios/new/HybridRiveFile.swift index 4477890a..7665e1a8 100644 --- a/ios/new/HybridRiveFile.swift +++ b/ios/new/HybridRiveFile.swift @@ -87,7 +87,13 @@ class HybridRiveFile: HybridRiveFileSpec { } let artboard = try await file.createArtboard(artboardName) - let vmInfo = try await file.getDefaultViewModelInfo(for: artboard) + // getDefaultViewModelInfo throws when the artboard has no default + // ViewModel — a normal state for VM-less files (issue #189 fixture), not + // a failure. Resolve nil like the legacy backend so callers get the + // documented "no ViewModel" result instead of a raw error. + guard let vmInfo = try? await file.getDefaultViewModelInfo(for: artboard) else { + return nil + } return HybridViewModel(file: file, vmName: vmInfo.viewModelName, worker: worker) } diff --git a/src/hooks/__tests__/useRive.test.ts b/src/hooks/__tests__/useRive.test.ts new file mode 100644 index 00000000..6fd337fd --- /dev/null +++ b/src/hooks/__tests__/useRive.test.ts @@ -0,0 +1,13 @@ +import { renderHook } from '@testing-library/react-native'; +import { useRive } from '../useRive'; + +describe('useRive', () => { + it('exposes an undefined (pending) riveViewRef before the view is ready, not null', () => { + // `undefined` = pending, `null` = failed — the useRiveFile convention. + // A null here would make useViewModelInstanceAsync(riveViewRef) settle to + // a terminal "no ViewModel" during normal mount (and throw with + // `required: true`) before the view has even attached. + const { result } = renderHook(() => useRive()); + expect(result.current.riveViewRef).toBeUndefined(); + }); +}); diff --git a/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx b/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx index 76744979..72a44e1a 100644 --- a/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx +++ b/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx @@ -212,8 +212,9 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { }); it('maps an artboard-lookup rejection to the not-found error', async () => { - // Both native platforms *throw* on an unknown artboard name rather than - // resolving undefined, so the rejection must map to the friendly message. + // The new backend *throws* on an unknown artboard name while the legacy + // backend resolves undefined; a rejection must map to the same friendly + // message the resolve-undefined path produces. const mockRiveFile = createMockRiveFile({}); (mockRiveFile.defaultArtboardViewModelAsync as jest.Mock).mockRejectedValue( new Error('Artboard not found in file') diff --git a/src/hooks/useRive.ts b/src/hooks/useRive.ts index 078a77ee..1a3d187c 100644 --- a/src/hooks/useRive.ts +++ b/src/hooks/useRive.ts @@ -3,7 +3,13 @@ import type { RiveViewRef } from '@rive-app/react-native'; export function useRive() { const riveRef = useRef(null); - const [riveViewRef, setRiveViewRef] = useState(null); + // `undefined` = view not ready yet, `null` = failed/detached — the same + // convention as useRiveFile, so hooks consuming the ref (e.g. + // useViewModelInstanceAsync) stay in their loading state until the view is + // actually ready instead of settling on a transient null. + const [riveViewRef, setRiveViewRef] = useState< + RiveViewRef | null | undefined + >(undefined); const timeoutRef = useRef | null>(null); const setRef = useCallback((node: RiveViewRef | null) => { diff --git a/src/hooks/useViewModelInstanceAsync.ts b/src/hooks/useViewModelInstanceAsync.ts index 8bcf2d6a..019626c6 100644 --- a/src/hooks/useViewModelInstanceAsync.ts +++ b/src/hooks/useViewModelInstanceAsync.ts @@ -31,10 +31,11 @@ type CreateInstanceResult = { }; // The message stays clean and stable ("… not found"); when the native call -// *rejected* (a real creation failure) the runtime error is attached as -// `cause` so its diagnostic (e.g. iOS reports which view model it came from) is -// preserved without leaking into the message. A plain resolve-without-instance -// (Android's missing-name path returns null) carries no cause. See #305. +// *rejected* the runtime error is attached as `cause` so its diagnostic is +// preserved without leaking into the message. Only some backends reject on a +// bad name (the new iOS backend throws `invalidViewModelInstance`); the +// legacy backends and Android's pre-check resolve null instead, so a missing +// `cause` is normal. See #305. function instanceNotFoundError(instanceName: string, cause?: unknown): Error { return new Error( `ViewModel instance '${instanceName}' not found`, @@ -75,10 +76,11 @@ async function createInstanceAsync( artboardName ? ArtboardByName(artboardName) : undefined ); } catch (e) { - // Both platforms *throw* on an unknown artboard name (iOS - // `createArtboard`, Android `Artboard.fromFile`) rather than resolving - // undefined, so map the rejection to the not-found error below instead - // of leaking the raw native message. Without a name it's a real error. + // The new backend *throws* on an unknown artboard name (iOS + // `createArtboard`, Android `Artboard.fromFile`) while the legacy + // backend resolves undefined — map the rejection to the same + // not-found error below instead of leaking the raw native message. + // Without a name a rejection is a real error. if (!artboardName) throw e; viewModel = undefined; } @@ -183,9 +185,10 @@ const LOADING_RESULT: UseViewModelInstanceAsyncResult = { * * A `null` source resolves to a terminal `{ instance: null, isLoading: false }` * (not perpetual loading), while an `undefined` source keeps the hook loading. - * This mirrors {@link useRiveFile}, which returns `riveFile: undefined` while - * loading and `riveFile: null` on error — so when chaining the two, check the - * file's own `error`, since this hook cannot observe why the source is absent: + * This mirrors {@link useRiveFile} (`riveFile: undefined` while loading, + * `null` on error) and `useRive` (`riveViewRef: undefined` until the view is + * ready, `null` on failure) — so when chaining, check the upstream hook's own + * `error`, since this hook cannot observe why the source is absent: * * ```tsx * const { riveFile, error: fileError } = useRiveFile(source); From 35407709f01643f41ef0b53b9168336c931d925d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 7 Jul 2026 16:34:38 +0200 Subject: [PATCH 7/7] fix(hooks): poll for the view-bound instance in useViewModelInstanceAsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-bound ViewModelInstance resolves a short time after the view ref is assigned and there is no native bind-complete signal yet, so the one-shot getViewModelInstance() read could settle a terminal { instance: null } on fast mounts (reproduced on-device with a raw hybridRef that exposes the ref in the pre-bind window; the useRive path happened to win the race). The ref path now polls every 50ms for up to 5s, aborted on unmount/deps-change; a view with no data binding still settles null, just late. New e2e + unit tests cover the ref source path, which previously had none (including the instance-is-not- disposed-on-unmount invariant). Also from the review follow-ups: - attach the native rejection as cause on the artboard not-found error (parity with instanceNotFoundError) - preserve non-Error rejection values in the outer catch - QuickStart: riveViewRef?.play() — buttons are tappable before the view mounts - CI: collect iOS crash reports on test-harness-ios-legacy failures too (the legacy backend is where native crashes reproduced) --- .github/workflows/ci.yml | 39 +++++++ .../useViewModelInstanceAsync-e2e.harness.tsx | 96 ++++++++++++++++- example/src/demos/QuickStart.tsx | 6 +- .../useViewModelInstanceAsync.test.tsx | 100 ++++++++++++++++++ src/hooks/useViewModelInstanceAsync.ts | 37 +++++-- 5 files changed, 264 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d682edc..276491ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -657,6 +657,45 @@ jobs: echo "=== Checking simulator logs for errors ===" xcrun simctl spawn booted log show --predicate 'processImagePath CONTAINS "RiveExample"' --last 5m --style compact 2>&1 | tail -200 || echo "No logs found" + - name: Debug - Collect iOS crash reports + if: failure() || cancelled() + run: | + set +e + UDID=$(xcrun simctl list devices | awk -F '[()]' '/Booted/{print $2; exit}') + echo "Booted simulator UDID: ${UDID:-}" + mkdir -p crash-reports + # Simulator app crashes are written to the host's DiagnosticReports by + # ReportCrash, and sometimes inside the simulator's own data dir. Grab + # RiveExample-named reports plus anything from the last 25 min as a fallback. + for dir in \ + "$HOME/Library/Logs/DiagnosticReports" \ + "$HOME/Library/Developer/CoreSimulator/Devices/$UDID/data/Library/Logs/DiagnosticReports"; do + [ -d "$dir" ] || continue + find "$dir" -type f \ + \( -iname 'RiveExample-*' -o \( \( -iname '*.ips' -o -iname '*.crash' \) -mmin -25 \) \) \ + -exec cp {} crash-reports/ \; 2>/dev/null + done + count=$(ls -1 crash-reports 2>/dev/null | wc -l | tr -d ' ') + echo "=== Collected ${count} crash report(s) ===" + for f in crash-reports/*; do + [ -e "$f" ] || continue + echo "---------------- ${f} ----------------" + cat "$f" + echo + done + if [ "${count}" = "0" ]; then + echo "No crash reports found (app may not have crashed, or reports were not flushed yet)." + fi + exit 0 + + - name: Upload iOS crash reports + if: failure() || cancelled() + uses: actions/upload-artifact@v4 + with: + name: ios-harness-legacy-crash-reports + path: crash-reports/ + if-no-files-found: ignore + test-harness-android-legacy: runs-on: ubuntu-latest timeout-minutes: 30 diff --git a/example/__tests__/useViewModelInstanceAsync-e2e.harness.tsx b/example/__tests__/useViewModelInstanceAsync-e2e.harness.tsx index 660c7eea..abb5b28d 100644 --- a/example/__tests__/useViewModelInstanceAsync-e2e.harness.tsx +++ b/example/__tests__/useViewModelInstanceAsync-e2e.harness.tsx @@ -6,14 +6,18 @@ import { waitFor, cleanup, } from 'react-native-harness'; -import { useEffect } from 'react'; -import { Text, View } from 'react-native'; +import { useEffect, useState } from 'react'; +import { Platform, Text, View } from 'react-native'; import { + Fit, RiveFileFactory, + RiveView, + useRive, useRiveFile, useViewModelInstanceAsync, type RiveFile, type RiveFileInput, + type RiveViewRef, type ViewModelInstance, } from '@rive-app/react-native'; @@ -24,6 +28,8 @@ import { const MULTI_AB = require('../assets/rive/arbtboards-models-instances.riv'); // ViewModels exist but no artboard default (issue #189 fixture). const NO_DEFAULT_VM = require('../assets/rive/nodefaultbouncing.riv'); +// Default artboard auto-binds a default ViewModel. +const BOUNCING_BALL = require('../assets/rive/bouncing_ball.riv'); function expectDefined(value: T): asserts value is NonNullable { expect(value).toBeDefined(); @@ -339,6 +345,92 @@ describe('useViewModelInstanceAsync: instance creation failure', () => { }); }); +function RefSourceConsumer({ file, ctx }: { file: RiveFile; ctx: AsyncCtx }) { + const { riveViewRef, setHybridRef } = useRive(); + const { instance, error, isLoading } = useViewModelInstanceAsync(riveViewRef); + useEffect(() => { + ctx.instance = instance; + ctx.error = error; + ctx.isLoading = isLoading; + }, [ctx, instance, error, isLoading]); + return ( + + + + ); +} + +function EagerRefConsumer({ file, ctx }: { file: RiveFile; ctx: AsyncCtx }) { + // Raw hybridRef: the ref is exposed the moment the native view attaches, + // before awaitViewReady/auto-bind complete — the widest race window. + const [viewRef, setViewRef] = useState(undefined); + const { instance, error, isLoading } = useViewModelInstanceAsync(viewRef); + useEffect(() => { + ctx.instance = instance; + ctx.error = error; + ctx.isLoading = isLoading; + }, [ctx, instance, error, isLoading]); + return ( + + setViewRef(ref ?? undefined), + }} + style={{ flex: 1 }} + file={file} + autoPlay={true} + fit={Fit.Contain} + /> + + ); +} + +// ── RiveViewRef source: the auto-bound instance must be delivered ──── +// The auto-bound ViewModelInstance resolves asynchronously a short time after +// the view ref is assigned (see autoplay.harness.tsx), so a one-shot +// getViewModelInstance() read races the bind and can settle a terminal +// { instance: null } on fast mounts. + +describe('useViewModelInstanceAsync: RiveViewRef source', () => { + it('resolves the auto-bound instance from a useRive view ref', async () => { + // getViewModelInstance() returns null on Android experimental — auto-bind + // doesn't expose the VMI handle to JS yet (same skip as autoplay.harness). + const isAndroidExperimental = + Platform.OS === 'android' && + RiveFileFactory.getBackend() === 'experimental'; + if (isAndroidExperimental) return; + + const file = await RiveFileFactory.fromSource(BOUNCING_BALL, undefined); + const ctx = createCtx(); + await render(); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 10000 }); + expect(ctx.error).toBeNull(); + expect(ctx.instance).toBeTruthy(); + cleanup(); + }); + + it('resolves the auto-bound instance from a raw hybridRef (pre-bind window)', async () => { + const isAndroidExperimental = + Platform.OS === 'android' && + RiveFileFactory.getBackend() === 'experimental'; + if (isAndroidExperimental) return; + + const file = await RiveFileFactory.fromSource(BOUNCING_BALL, undefined); + const ctx = createCtx(); + await render(); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 10000 }); + expect(ctx.error).toBeNull(); + expect(ctx.instance).toBeTruthy(); + cleanup(); + }); +}); + // ── A VM-less default artboard is "no ViewModel", not an error ─────── // The new backend's getDefaultViewModelInfo throws when the artboard has no // default ViewModel, which would surface a raw native error for a perfectly diff --git a/example/src/demos/QuickStart.tsx b/example/src/demos/QuickStart.tsx index 62a82eb8..d24d67ad 100644 --- a/example/src/demos/QuickStart.tsx +++ b/example/src/demos/QuickStart.tsx @@ -40,18 +40,18 @@ export default function QuickStart() { const handleTakeDamage = () => { setHealth((h) => (h ?? 0) - 7); - riveViewRef!.play(); + riveViewRef?.play(); }; const handleMaxHealth = () => { setHealth(100); - riveViewRef!.play(); + riveViewRef?.play(); }; const handleGameOver = () => { setHealth(0); gameOverTrigger(); - riveViewRef!.play(); + riveViewRef?.play(); }; if (fileError || error) { diff --git a/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx b/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx index 72a44e1a..f73eff0c 100644 --- a/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx +++ b/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx @@ -9,6 +9,7 @@ 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'; +import type { RiveViewRef } from '../../index'; function createMockViewModelInstance(name = 'TestInstance'): ViewModelInstance { return { @@ -230,6 +231,41 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { expect(result.current.error?.message).toBe( "Artboard 'NonExistent' not found or has no ViewModel" ); + // The native diagnostic must survive as `cause`, not be swallowed. + expect((result.current.error as Error & { cause?: unknown }).cause).toEqual( + new Error('Artboard not found in file') + ); + }); + + it('artboard resolve-undefined miss carries no cause', async () => { + const mockRiveFile = createMockRiveFile({}); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile, { artboardName: 'NonExistent' }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error?.message).toBe( + "Artboard 'NonExistent' not found or has no ViewModel" + ); + expect( + (result.current.error as Error & { cause?: unknown }).cause + ).toBeUndefined(); + }); + + it('preserves the value of a non-Error rejection in the error message', async () => { + const defaultViewModel = createMockViewModel(); + ( + defaultViewModel.createDefaultInstanceAsync as jest.Mock + ).mockRejectedValue('file was disposed'); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result } = renderHook(() => + useViewModelInstanceAsync(mockRiveFile) + ); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(result.current.error?.message).toBe('file was disposed'); }); it('propagates a rejection unchanged when no artboardName was given', async () => { @@ -291,6 +327,70 @@ describe('useViewModelInstanceAsync - ViewModel source', () => { }); }); +function createMockRiveViewRef( + getViewModelInstance: () => ViewModelInstance | null | undefined +): RiveViewRef { + return { getViewModelInstance: jest.fn(getViewModelInstance) } as any; +} + +describe('useViewModelInstanceAsync - RiveViewRef source', () => { + it('resolves the view-bound instance', async () => { + const vmi = createMockViewModelInstance(); + const ref = createMockRiveViewRef(() => vmi); + + const { result } = renderHook(() => useViewModelInstanceAsync(ref)); + + await waitFor(() => expect(result.current.instance).toBe(vmi)); + expect(result.current.error).toBeNull(); + }); + + it('does not dispose a view-owned instance on unmount', async () => { + const vmi = createMockViewModelInstance(); + const ref = createMockRiveViewRef(() => vmi); + + const { result, unmount } = renderHook(() => + useViewModelInstanceAsync(ref) + ); + + await waitFor(() => expect(result.current.instance).toBe(vmi)); + unmount(); + expect(vmi.dispose).not.toHaveBeenCalled(); + }); + + it('retries until the auto-bound instance appears', async () => { + // Auto-bind completes a short time after the ref is assigned; the hook + // must poll rather than settle a terminal null on the first read. + const vmi = createMockViewModelInstance(); + let calls = 0; + const ref = createMockRiveViewRef(() => (++calls >= 3 ? vmi : undefined)); + + const { result } = renderHook(() => useViewModelInstanceAsync(ref)); + + expect(result.current.isLoading).toBe(true); + await waitFor(() => expect(result.current.instance).toBe(vmi), { + timeout: 2000, + }); + expect(calls).toBeGreaterThanOrEqual(3); + }); + + it('stops polling when unmounted before the instance appears', async () => { + const getVmi = jest.fn((): ViewModelInstance | undefined => undefined); + const ref = { getViewModelInstance: getVmi } as unknown as RiveViewRef; + + const { unmount } = renderHook(() => useViewModelInstanceAsync(ref)); + unmount(); + + await act(async () => { + await new Promise((r) => setTimeout(r, 200)); + }); + const callsAfterUnmount = getVmi.mock.calls.length; + await act(async () => { + await new Promise((r) => setTimeout(r, 200)); + }); + expect(getVmi.mock.calls.length).toBe(callsAfterUnmount); + }); +}); + describe('useViewModelInstanceAsync - null vs undefined source', () => { it('settles to a terminal null when the source is null (e.g. file load failed)', async () => { const { result } = renderHook(() => useViewModelInstanceAsync(null)); diff --git a/src/hooks/useViewModelInstanceAsync.ts b/src/hooks/useViewModelInstanceAsync.ts index 019626c6..443795e3 100644 --- a/src/hooks/useViewModelInstanceAsync.ts +++ b/src/hooks/useViewModelInstanceAsync.ts @@ -43,19 +43,37 @@ function instanceNotFoundError(instanceName: string, cause?: unknown): Error { ); } +// The view's auto-bound instance resolves asynchronously a short time after +// the ref is assigned and there is no native bind-complete signal to await +// yet, so a one-shot getViewModelInstance() read would settle a terminal null +// on fast mounts. Poll briefly instead; a view with no data binding resolves +// null after the timeout (late, but the correct terminal state). +const REF_BIND_POLL_MS = 50; +const REF_BIND_TIMEOUT_MS = 5000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + async function createInstanceAsync( source: ViewModelSource | null | undefined, instanceName: string | undefined, artboardName: string | undefined, viewModelName: string | undefined, - useNew: boolean + useNew: boolean, + isCancelled: () => boolean ): Promise { if (!source) { return { instance: undefined, needsDispose: false }; } if (isRiveViewRef(source)) { - const vmi = source.getViewModelInstance(); + let vmi = source.getViewModelInstance(); + const deadline = Date.now() + REF_BIND_TIMEOUT_MS; + while (!vmi && Date.now() < deadline && !isCancelled()) { + await sleep(REF_BIND_POLL_MS); + vmi = source.getViewModelInstance(); + } return { instance: vmi ?? null, needsDispose: false }; } @@ -71,6 +89,7 @@ async function createInstanceAsync( }; } } else { + let artboardCause: unknown; try { viewModel = await source.defaultArtboardViewModelAsync( artboardName ? ArtboardByName(artboardName) : undefined @@ -79,9 +98,10 @@ async function createInstanceAsync( // The new backend *throws* on an unknown artboard name (iOS // `createArtboard`, Android `Artboard.fromFile`) while the legacy // backend resolves undefined — map the rejection to the same - // not-found error below instead of leaking the raw native message. + // not-found error below, preserving the native diagnostic as `cause`. // Without a name a rejection is a real error. if (!artboardName) throw e; + artboardCause = e; viewModel = undefined; } if (!viewModel) { @@ -90,7 +110,8 @@ async function createInstanceAsync( instance: null, needsDispose: false, error: new Error( - `Artboard '${artboardName}' not found or has no ViewModel` + `Artboard '${artboardName}' not found or has no ViewModel`, + artboardCause !== undefined ? { cause: artboardCause } : undefined ), }; } @@ -332,7 +353,8 @@ export function useViewModelInstanceAsync( instanceName, artboardName, viewModelName, - useNew + useNew, + () => cancelled ); created = c; @@ -366,10 +388,7 @@ export function useViewModelInstanceAsync( setResult({ instance: null, isLoading: false, - error: - e instanceof Error - ? e - : new Error('Failed to create ViewModel instance'), + error: e instanceof Error ? e : new Error(String(e)), }); } })();