From ca35c66527837824dc06939522cf5f0115911ab8 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 01/19] 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 2a6eb0b5..c46dd571 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 0c9ea23060682d9b11d330491c9ed8f887e28261 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 02/19] =?UTF-8?q?test(harness):=20fix=20flaky=20reconfigur?= =?UTF-8?q?e=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 684f933fb1918109531d9b9b2837a9a0cabe47cc 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 03/19] =?UTF-8?q?test(harness):=20fix=20flaky=20autoplay?= =?UTF-8?q?=20auto-dataBind=20test=20=E2=80=94=20wait=20for=20ViewModel=20?= =?UTF-8?q?property?= 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 a8720c4ab2afc25322144e630f895aa2a48e8e61 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 04/19] 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 cfebb774..1149231c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -465,6 +465,45 @@ jobs: echo "=== Simulator logs (last 5m) ===" xcrun simctl spawn booted log show --predicate 'processImagePath CONTAINS "expo57example"' --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-crash-reports + path: crash-reports/ + if-no-files-found: ignore + test-harness-android: runs-on: ubuntu-latest timeout-minutes: 30 From d5643bb185e94bcad1df8209261319119c45d61e 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 05/19] 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 8d7f63bb45e5c31b0d77872aba8dee2e58876392 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 06/19] 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 | 6 +- .../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, 89 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 00db6600..79112a2c 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridRiveFile.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridRiveFile.kt @@ -100,11 +100,15 @@ class HybridRiveFile( Artboard.fromFile(file) } val vmSource = ViewModelSource.DefaultForArtboard(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) { runCatching { artboard.close() } - throw e + return null } return HybridViewModel(file, riveWorker, vmInfo.viewModelName, this, vmSource, ownedArtboard = artboard) } 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 84fd95fc..f62948c3 100644 --- a/ios/new/HybridRiveFile.swift +++ b/ios/new/HybridRiveFile.swift @@ -89,7 +89,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 217b319503f30219a228d71a12519526a95cca65 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 07/19] 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 1149231c..db69efa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -799,6 +799,45 @@ jobs: echo "=== Simulator logs (last 5m) ===" xcrun simctl spawn booted log show --predicate 'processImagePath CONTAINS "expo57example"' --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)), }); } })(); From 1877cc12b570c894e9f7ebea68ef7980f413767d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 8 Jul 2026 07:09:54 +0200 Subject: [PATCH 08/19] fix(hooks): dispose the intermediate ViewModel wrapper after instance creation The wrapper is hook-internal and on the experimental backend owns the artboard resolved for DefaultForArtboard sources; without an explicit dispose it lingers until JS GC finalizes it. --- src/hooks/useViewModelInstanceAsync.ts | 41 +++++++++++++++----------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/hooks/useViewModelInstanceAsync.ts b/src/hooks/useViewModelInstanceAsync.ts index 443795e3..6bef0ce4 100644 --- a/src/hooks/useViewModelInstanceAsync.ts +++ b/src/hooks/useViewModelInstanceAsync.ts @@ -118,31 +118,38 @@ async function createInstanceAsync( return { instance: null, needsDispose: false }; } } - let vmi: ViewModelInstance | undefined; - if (instanceName) { - try { - vmi = await viewModel.createInstanceByNameAsync(instanceName); - } catch (e) { + try { + let vmi: ViewModelInstance | undefined; + if (instanceName) { + try { + vmi = await viewModel.createInstanceByNameAsync(instanceName); + } catch (e) { + return { + instance: null, + needsDispose: false, + error: instanceNotFoundError(instanceName, e), + }; + } + } else { + vmi = await viewModel.createDefaultInstanceAsync(); + } + if (!vmi && instanceName) { return { instance: null, needsDispose: false, - error: instanceNotFoundError(instanceName, e), + error: instanceNotFoundError(instanceName), }; } - } else { - vmi = await viewModel.createDefaultInstanceAsync(); - } - if (!vmi && instanceName) { - return { - instance: null, - needsDispose: false, - error: instanceNotFoundError(instanceName), - }; + return { instance: vmi ?? null, needsDispose: true }; + } finally { + // The intermediate ViewModel wrapper is hook-internal; disposing it + // releases the native resources it owns (e.g. the artboard resolved + // for DefaultForArtboard sources on the experimental backend). + callDispose(viewModel); } - return { instance: vmi ?? null, needsDispose: true }; } - // ViewModel source + // ViewModel source (caller-owned — not disposed here) let vmi: ViewModelInstance | undefined; if (instanceName) { try { From 7db22bba922593d79029c1e52d2bb1d936d6100f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 8 Jul 2026 09:21:51 +0200 Subject: [PATCH 09/19] feat(hooks)!: fold the async variant into useViewModelInstance({ async: true }) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the separate useViewModelInstanceAsync export with an async param on useViewModelInstance, so the API keeps a single name and the migration is a param flip rather than a rename: - overloads with async: true return the async result; the non-async overloads are @deprecated per-overload ('pass async: true') and will flip to async-by-default in the next major - the sync result gains isLoading (true only while the source is pending), which makes the sync and async result unions structurally identical — call sites don't change shape when the default flips - the async implementation is unchanged and stays in useViewModelInstanceAsync.ts as the internal hook behind the flag; the async param must stay constant for a component's lifetime (it selects between hook implementations) - examples, unit tests, and the e2e harness now call useViewModelInstance(source, { async: true, ... }); repro/regression tests that intentionally ride the sync path carry explicit no-deprecated disables --- ...seViewModelInstance-async-e2e.harness.tsx} | 39 ++-- example/src/__tests__/issue297.hooks.test.tsx | 1 + .../src/__tests__/staleSetterWrite.test.tsx | 1 + .../src/demos/DataBindingArtboardsExample.tsx | 4 +- example/src/demos/QuickStart.tsx | 5 +- .../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 +- example/src/reproducers/ReloadRebind.tsx | 1 + src/hooks/__tests__/useRive.test.ts | 2 +- ...sx => useViewModelInstance.async.test.tsx} | 114 +++++++---- src/hooks/useRive.ts | 2 +- src/hooks/useViewModelInstance.ts | 191 ++++++++++++------ src/hooks/useViewModelInstanceAsync.ts | 5 +- src/index.tsx | 7 +- 17 files changed, 261 insertions(+), 131 deletions(-) rename example/__tests__/{useViewModelInstanceAsync-e2e.harness.tsx => useViewModelInstance-async-e2e.harness.tsx} (92%) rename src/hooks/__tests__/{useViewModelInstanceAsync.test.tsx => useViewModelInstance.async.test.tsx} (86%) diff --git a/example/__tests__/useViewModelInstanceAsync-e2e.harness.tsx b/example/__tests__/useViewModelInstance-async-e2e.harness.tsx similarity index 92% rename from example/__tests__/useViewModelInstanceAsync-e2e.harness.tsx rename to example/__tests__/useViewModelInstance-async-e2e.harness.tsx index abb5b28d..c72ba839 100644 --- a/example/__tests__/useViewModelInstanceAsync-e2e.harness.tsx +++ b/example/__tests__/useViewModelInstance-async-e2e.harness.tsx @@ -14,7 +14,7 @@ import { RiveView, useRive, useRiveFile, - useViewModelInstanceAsync, + useViewModelInstance, type RiveFile, type RiveFileInput, type RiveViewRef, @@ -58,7 +58,8 @@ function ArtboardProbe({ artboardName: string; ctx: AsyncCtx; }) { - const { instance, error, isLoading } = useViewModelInstanceAsync(file, { + const { instance, error, isLoading } = useViewModelInstance(file, { + async: true, artboardName, }); useEffect(() => { @@ -84,7 +85,8 @@ function ViewModelProbe({ onInit?: (vmi: ViewModelInstance) => void; ctx: AsyncCtx; }) { - const { instance, error, isLoading } = useViewModelInstanceAsync(file, { + const { instance, error, isLoading } = useViewModelInstance(file, { + async: true, viewModelName, onInit, }); @@ -111,7 +113,8 @@ function InstanceNameProbe({ instanceName: string; ctx: AsyncCtx; }) { - const { instance, error, isLoading } = useViewModelInstanceAsync(file, { + const { instance, error, isLoading } = useViewModelInstance(file, { + async: true, viewModelName, instanceName, }); @@ -134,7 +137,9 @@ function FixedSourceProbe({ source: RiveFile | null | undefined; ctx: AsyncCtx; }) { - const { instance, error, isLoading } = useViewModelInstanceAsync(source); + const { instance, error, isLoading } = useViewModelInstance(source, { + async: true, + }); useEffect(() => { ctx.instance = instance; ctx.error = error; @@ -157,7 +162,9 @@ function FileErrorProbe({ ctx: FileErrorCtx; }) { const { riveFile, error: fileError } = useRiveFile(input); - const { instance, error, isLoading } = useViewModelInstanceAsync(riveFile); + const { instance, error, isLoading } = useViewModelInstance(riveFile, { + async: true, + }); useEffect(() => { ctx.fileErrored = fileError != null; ctx.instance = instance; @@ -176,7 +183,7 @@ function FileErrorProbe({ // 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', () => { +describe('useViewModelInstance async: unknown artboard name', () => { it('surfaces the friendly "not found" error instead of the raw native message', async () => { const file = await loadFile(); const ctx = createCtx(); @@ -211,7 +218,7 @@ describe('useViewModelInstanceAsync: unknown artboard name', () => { // 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', () => { +describe('useViewModelInstance async: onInit', () => { it('surfaces an onInit throw as error and leaves instance null', async () => { const file = await loadFile(); const ctx = createCtx(); @@ -258,7 +265,7 @@ describe('useViewModelInstanceAsync: onInit', () => { // terminate on `null`, otherwise a consumer keying a spinner off `isLoading` // hangs forever with no signal. -describe('useViewModelInstanceAsync: null vs undefined source', () => { +describe('useViewModelInstance async: null vs undefined source', () => { it('stays loading while the source is undefined (file still resolving)', async () => { const ctx = createCtx(); await render(); @@ -299,7 +306,7 @@ describe('useViewModelInstanceAsync: null vs undefined source', () => { // `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', () => { +describe('useViewModelInstance async: instance creation failure', () => { it('keeps a clean not-found message on every backend (cause optional)', async () => { const file = await loadFile(); const ctx = createCtx(); @@ -347,7 +354,9 @@ describe('useViewModelInstanceAsync: instance creation failure', () => { function RefSourceConsumer({ file, ctx }: { file: RiveFile; ctx: AsyncCtx }) { const { riveViewRef, setHybridRef } = useRive(); - const { instance, error, isLoading } = useViewModelInstanceAsync(riveViewRef); + const { instance, error, isLoading } = useViewModelInstance(riveViewRef, { + async: true, + }); useEffect(() => { ctx.instance = instance; ctx.error = error; @@ -370,7 +379,9 @@ 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); + const { instance, error, isLoading } = useViewModelInstance(viewRef, { + async: true, + }); useEffect(() => { ctx.instance = instance; ctx.error = error; @@ -397,7 +408,7 @@ function EagerRefConsumer({ file, ctx }: { file: RiveFile; ctx: AsyncCtx }) { // getViewModelInstance() read races the bind and can settle a terminal // { instance: null } on fast mounts. -describe('useViewModelInstanceAsync: RiveViewRef source', () => { +describe('useViewModelInstance async: 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). @@ -438,7 +449,7 @@ describe('useViewModelInstanceAsync: RiveViewRef source', () => { // 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', () => { +describe('useViewModelInstance async: file without a default ViewModel', () => { it('resolves null with no error', async () => { const file = await RiveFileFactory.fromSource(NO_DEFAULT_VM, undefined); const ctx = createCtx(); diff --git a/example/src/__tests__/issue297.hooks.test.tsx b/example/src/__tests__/issue297.hooks.test.tsx index a8ec925a..5e4c9c52 100644 --- a/example/src/__tests__/issue297.hooks.test.tsx +++ b/example/src/__tests__/issue297.hooks.test.tsx @@ -36,6 +36,7 @@ function AgeHook({ instance, ctx }: { instance: ViewModelInstance; ctx: Ctx }) { } function Repro({ file, ctx }: { file: RiveFile; ctx: Ctx }) { + // eslint-disable-next-line @typescript-eslint/no-deprecated -- #297 regression rides the sync creation path on purpose const { instance } = useViewModelInstance(file); const [hookKey, setHookKey] = useState(0); useEffect(() => { diff --git a/example/src/__tests__/staleSetterWrite.test.tsx b/example/src/__tests__/staleSetterWrite.test.tsx index c29ad877..e304cc25 100644 --- a/example/src/__tests__/staleSetterWrite.test.tsx +++ b/example/src/__tests__/staleSetterWrite.test.tsx @@ -34,6 +34,7 @@ function AgeHook({ instance, ctx }: { instance: ViewModelInstance; ctx: Ctx }) { } function Repro({ file, ctx }: { file: RiveFile; ctx: Ctx }) { + // eslint-disable-next-line @typescript-eslint/no-deprecated -- stale-write regression rides the sync creation path on purpose const { instance } = useViewModelInstance(file); if (!instance) return null; return ( diff --git a/example/src/demos/DataBindingArtboardsExample.tsx b/example/src/demos/DataBindingArtboardsExample.tsx index 49cc9f36..1bd553d2 100644 --- a/example/src/demos/DataBindingArtboardsExample.tsx +++ b/example/src/demos/DataBindingArtboardsExample.tsx @@ -10,7 +10,7 @@ import { Fit, RiveView, useRiveFile, - useViewModelInstanceAsync, + useViewModelInstance, type RiveFile, type BindableArtboard, } from '@rive-app/react-native'; @@ -78,7 +78,7 @@ function ArtboardSwapper({ mainFile: RiveFile; assetsFile: RiveFile; }) { - const { instance, error } = useViewModelInstanceAsync(mainFile); + const { instance, error } = useViewModelInstance(mainFile, { async: true }); const [currentArtboard, setCurrentArtboard] = useState('Dragon'); const initializedRef = useRef(false); diff --git a/example/src/demos/QuickStart.tsx b/example/src/demos/QuickStart.tsx index d24d67ad..5850b5ee 100644 --- a/example/src/demos/QuickStart.tsx +++ b/example/src/demos/QuickStart.tsx @@ -13,7 +13,7 @@ import { useRiveFile, useRiveNumber, useRiveTrigger, - useViewModelInstanceAsync, + useViewModelInstance, Fit, } from '@rive-app/react-native'; import type { Metadata } from '../shared/metadata'; @@ -23,9 +23,10 @@ export default function QuickStart() { require('../../assets/rive/quick_start.riv') ); const { riveViewRef, setHybridRef } = useRive(); - const { instance: viewModelInstance, error } = useViewModelInstanceAsync( + const { instance: viewModelInstance, error } = useViewModelInstance( riveFile, { + async: true, onInit: (vmi) => vmi.numberProperty('health')!.set(9), } ); diff --git a/example/src/exercisers/FontFallbackExample.tsx b/example/src/exercisers/FontFallbackExample.tsx index d2eb5a80..ced591fc 100644 --- a/example/src/exercisers/FontFallbackExample.tsx +++ b/example/src/exercisers/FontFallbackExample.tsx @@ -15,7 +15,7 @@ import { useRive, useRiveFile, useRiveString, - useViewModelInstanceAsync, + useViewModelInstance, 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 } = useViewModelInstanceAsync(riveFile); + const { instance } = useViewModelInstance(riveFile, { async: true }); const { setValue: setRiveText, error: textError } = useRiveString( TEXT_PROPERTY, diff --git a/example/src/exercisers/MenuListExample.tsx b/example/src/exercisers/MenuListExample.tsx index c46dd571..47b72c16 100644 --- a/example/src/exercisers/MenuListExample.tsx +++ b/example/src/exercisers/MenuListExample.tsx @@ -16,7 +16,7 @@ import { type RiveFile, useRiveFile, useRiveList, - useViewModelInstanceAsync, + useViewModelInstance, } 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 } = useViewModelInstanceAsync(file); + const { instance, error } = useViewModelInstance(file, { async: true }); if (error) { console.error(error.message); diff --git a/example/src/exercisers/NestedViewModelExample.tsx b/example/src/exercisers/NestedViewModelExample.tsx index 60cc3cba..5efd9998 100644 --- a/example/src/exercisers/NestedViewModelExample.tsx +++ b/example/src/exercisers/NestedViewModelExample.tsx @@ -13,7 +13,7 @@ import { RiveView, useRiveFile, useRiveString, - useViewModelInstanceAsync, + useViewModelInstance, type ViewModelInstance, type RiveFile, type RiveViewRef, @@ -41,7 +41,7 @@ export default function NestedViewModelExample() { } function WithViewModelSetup({ file }: { file: RiveFile }) { - const { instance, error } = useViewModelInstanceAsync(file); + const { instance, error } = useViewModelInstance(file, { async: true }); if (error) { console.error(error.message); diff --git a/example/src/exercisers/RiveDataBindingExample.tsx b/example/src/exercisers/RiveDataBindingExample.tsx index 7b5a9aa7..48713b79 100644 --- a/example/src/exercisers/RiveDataBindingExample.tsx +++ b/example/src/exercisers/RiveDataBindingExample.tsx @@ -4,7 +4,7 @@ import { Fit, RiveView, useRiveNumber, - useViewModelInstanceAsync, + useViewModelInstance, type ViewModelInstance, type RiveFile, useRiveString, @@ -37,7 +37,7 @@ export default function WithRiveFile() { } function WithViewModelSetup({ file }: { file: RiveFile }) { - const { instance, error } = useViewModelInstanceAsync(file); + const { instance, error } = useViewModelInstance(file, { async: true }); if (error) { console.error(error.message); diff --git a/example/src/reproducers/Issue297ThreadRace.tsx b/example/src/reproducers/Issue297ThreadRace.tsx index 1699a124..02734dd1 100644 --- a/example/src/reproducers/Issue297ThreadRace.tsx +++ b/example/src/reproducers/Issue297ThreadRace.tsx @@ -11,7 +11,7 @@ import { Fit, RiveView, useRiveFile, - useViewModelInstanceAsync, + useViewModelInstance, type ViewModelInstance, type RiveFile, } from '@rive-app/react-native'; @@ -130,7 +130,7 @@ function StressRunner({ } function WithViewModelSetup({ file }: { file: RiveFile }) { - const { instance, error } = useViewModelInstanceAsync(file); + const { instance, error } = useViewModelInstance(file, { async: true }); if (error) { return {error.message}; diff --git a/example/src/reproducers/ReloadRebind.tsx b/example/src/reproducers/ReloadRebind.tsx index b52b1f6c..3632d6cb 100644 --- a/example/src/reproducers/ReloadRebind.tsx +++ b/example/src/reproducers/ReloadRebind.tsx @@ -26,6 +26,7 @@ export default function ReloadRebind() { const { riveFile } = useRiveFile( require('../../assets/rive/quick_start.riv') ); + // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentionally exercises the deprecated sync creation path const { instance: viewModelInstance } = useViewModelInstance(riveFile, { onInit: (vmi) => vmi.numberProperty('health')!.set(50), }); diff --git a/src/hooks/__tests__/useRive.test.ts b/src/hooks/__tests__/useRive.test.ts index 6fd337fd..8d318caf 100644 --- a/src/hooks/__tests__/useRive.test.ts +++ b/src/hooks/__tests__/useRive.test.ts @@ -4,7 +4,7 @@ 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 null here would make useViewModelInstance({async: true})(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()); diff --git a/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx b/src/hooks/__tests__/useViewModelInstance.async.test.tsx similarity index 86% rename from src/hooks/__tests__/useViewModelInstanceAsync.test.tsx rename to src/hooks/__tests__/useViewModelInstance.async.test.tsx index f73eff0c..3827159d 100644 --- a/src/hooks/__tests__/useViewModelInstanceAsync.test.tsx +++ b/src/hooks/__tests__/useViewModelInstance.async.test.tsx @@ -5,7 +5,7 @@ import { waitFor, act, } from '@testing-library/react-native'; -import { useViewModelInstanceAsync } from '../useViewModelInstanceAsync'; +import { useViewModelInstance } from '../useViewModelInstance'; import type { RiveFile } from '../../specs/RiveFile.nitro'; import type { ViewModel, ViewModelInstance } from '../../specs/ViewModel.nitro'; import type { ArtboardBy } from '../../specs/ArtboardBy'; @@ -68,14 +68,14 @@ function createMockRiveFile(options?: { } as any; } -describe('useViewModelInstanceAsync - RiveFile source', () => { +describe('useViewModelInstance async - 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) + useViewModelInstance(mockRiveFile, { async: true }) ); expect(result.current.isLoading).toBe(true); @@ -97,7 +97,8 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { const mockRiveFile = createMockRiveFile({ defaultViewModel }); const { result } = renderHook(() => - useViewModelInstanceAsync(mockRiveFile, { + useViewModelInstance(mockRiveFile, { + async: true, instanceName: 'PersonInstance', }) ); @@ -119,7 +120,10 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { }); const { result } = renderHook(() => - useViewModelInstanceAsync(mockRiveFile, { artboardName: 'MainArtboard' }) + useViewModelInstance(mockRiveFile, { + async: true, + artboardName: 'MainArtboard', + }) ); await waitFor(() => expect(result.current.instance).toBe(mainInstance)); @@ -139,7 +143,10 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { }); const { result } = renderHook(() => - useViewModelInstanceAsync(mockRiveFile, { viewModelName: 'Settings' }) + useViewModelInstance(mockRiveFile, { + async: true, + viewModelName: 'Settings', + }) ); await waitFor(() => expect(result.current.instance).toBe(settingsInstance)); @@ -152,7 +159,10 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { const mockRiveFile = createMockRiveFile({ defaultViewModel }); const { result } = renderHook(() => - useViewModelInstanceAsync(mockRiveFile, { instanceName: 'NonExistent' }) + useViewModelInstance(mockRiveFile, { + async: true, + instanceName: 'NonExistent', + }) ); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -174,7 +184,10 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { const mockRiveFile = createMockRiveFile({ defaultViewModel }); const { result } = renderHook(() => - useViewModelInstanceAsync(mockRiveFile, { instanceName: 'Whatever' }) + useViewModelInstance(mockRiveFile, { + async: true, + instanceName: 'Whatever', + }) ); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -190,7 +203,10 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { const mockRiveFile = createMockRiveFile({ defaultViewModel }); const { result } = renderHook(() => - useViewModelInstanceAsync(mockRiveFile, { instanceName: 'Missing' }) + useViewModelInstance(mockRiveFile, { + async: true, + instanceName: 'Missing', + }) ); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -204,7 +220,7 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { const mockRiveFile = createMockRiveFile({}); const { result } = renderHook(() => - useViewModelInstanceAsync(mockRiveFile) + useViewModelInstance(mockRiveFile, { async: true }) ); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -222,7 +238,10 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { ); const { result } = renderHook(() => - useViewModelInstanceAsync(mockRiveFile, { artboardName: 'NonExistent' }) + useViewModelInstance(mockRiveFile, { + async: true, + artboardName: 'NonExistent', + }) ); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -241,7 +260,10 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { const mockRiveFile = createMockRiveFile({}); const { result } = renderHook(() => - useViewModelInstanceAsync(mockRiveFile, { artboardName: 'NonExistent' }) + useViewModelInstance(mockRiveFile, { + async: true, + artboardName: 'NonExistent', + }) ); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -261,7 +283,7 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { const mockRiveFile = createMockRiveFile({ defaultViewModel }); const { result } = renderHook(() => - useViewModelInstanceAsync(mockRiveFile) + useViewModelInstance(mockRiveFile, { async: true }) ); await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); @@ -275,7 +297,7 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { ); const { result } = renderHook(() => - useViewModelInstanceAsync(mockRiveFile) + useViewModelInstance(mockRiveFile, { async: true }) ); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -284,13 +306,13 @@ describe('useViewModelInstanceAsync - RiveFile source', () => { }); }); -describe('useViewModelInstanceAsync - ViewModel source', () => { +describe('useViewModelInstance async - ViewModel source', () => { it('uses createDefaultInstanceAsync by default', async () => { const defaultInstance = createMockViewModelInstance(); const mockViewModel = createMockViewModel({ defaultInstance }); const { result } = renderHook(() => - useViewModelInstanceAsync(mockViewModel) + useViewModelInstance(mockViewModel, { async: true }) ); await waitFor(() => expect(result.current.instance).toBe(defaultInstance)); @@ -302,7 +324,10 @@ describe('useViewModelInstanceAsync - ViewModel source', () => { const mockViewModel = createMockViewModel({ blankInstance }); const { result } = renderHook(() => - useViewModelInstanceAsync(mockViewModel, { useNew: true }) + useViewModelInstance(mockViewModel, { + async: true, + useNew: true, + }) ); await waitFor(() => expect(result.current.instance).toBe(blankInstance)); @@ -317,7 +342,10 @@ describe('useViewModelInstanceAsync - ViewModel source', () => { }); const { result } = renderHook(() => - useViewModelInstanceAsync(mockViewModel, { name: 'Gordon' }) + useViewModelInstance(mockViewModel, { + async: true, + name: 'Gordon', + }) ); await waitFor(() => expect(result.current.instance).toBe(namedInstance)); @@ -333,12 +361,14 @@ function createMockRiveViewRef( return { getViewModelInstance: jest.fn(getViewModelInstance) } as any; } -describe('useViewModelInstanceAsync - RiveViewRef source', () => { +describe('useViewModelInstance async - RiveViewRef source', () => { it('resolves the view-bound instance', async () => { const vmi = createMockViewModelInstance(); const ref = createMockRiveViewRef(() => vmi); - const { result } = renderHook(() => useViewModelInstanceAsync(ref)); + const { result } = renderHook(() => + useViewModelInstance(ref, { async: true }) + ); await waitFor(() => expect(result.current.instance).toBe(vmi)); expect(result.current.error).toBeNull(); @@ -349,7 +379,7 @@ describe('useViewModelInstanceAsync - RiveViewRef source', () => { const ref = createMockRiveViewRef(() => vmi); const { result, unmount } = renderHook(() => - useViewModelInstanceAsync(ref) + useViewModelInstance(ref, { async: true }) ); await waitFor(() => expect(result.current.instance).toBe(vmi)); @@ -364,7 +394,9 @@ describe('useViewModelInstanceAsync - RiveViewRef source', () => { let calls = 0; const ref = createMockRiveViewRef(() => (++calls >= 3 ? vmi : undefined)); - const { result } = renderHook(() => useViewModelInstanceAsync(ref)); + const { result } = renderHook(() => + useViewModelInstance(ref, { async: true }) + ); expect(result.current.isLoading).toBe(true); await waitFor(() => expect(result.current.instance).toBe(vmi), { @@ -377,7 +409,9 @@ describe('useViewModelInstanceAsync - RiveViewRef source', () => { const getVmi = jest.fn((): ViewModelInstance | undefined => undefined); const ref = { getViewModelInstance: getVmi } as unknown as RiveViewRef; - const { unmount } = renderHook(() => useViewModelInstanceAsync(ref)); + const { unmount } = renderHook(() => + useViewModelInstance(ref, { async: true }) + ); unmount(); await act(async () => { @@ -391,9 +425,11 @@ describe('useViewModelInstanceAsync - RiveViewRef source', () => { }); }); -describe('useViewModelInstanceAsync - null vs undefined source', () => { +describe('useViewModelInstance async - 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)); + const { result } = renderHook(() => + useViewModelInstance(null, { async: true }) + ); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.instance).toBeNull(); @@ -401,7 +437,9 @@ describe('useViewModelInstanceAsync - null vs undefined source', () => { }); it('stays in the loading state when the source is undefined (still resolving)', async () => { - const { result } = renderHook(() => useViewModelInstanceAsync(undefined)); + const { result } = renderHook(() => + useViewModelInstance(undefined, { async: true }) + ); expect(result.current.isLoading).toBe(true); expect(result.current.instance).toBeUndefined(); @@ -415,7 +453,7 @@ describe('useViewModelInstanceAsync - null vs undefined source', () => { }); }); -describe('useViewModelInstanceAsync - onInit', () => { +describe('useViewModelInstance async - onInit', () => { it('calls onInit with the resolved instance', async () => { const defaultInstance = createMockViewModelInstance(); const defaultViewModel = createMockViewModel({ defaultInstance }); @@ -423,7 +461,10 @@ describe('useViewModelInstanceAsync - onInit', () => { const onInit = jest.fn(); const { result } = renderHook(() => - useViewModelInstanceAsync(mockRiveFile, { onInit }) + useViewModelInstance(mockRiveFile, { + async: true, + onInit, + }) ); await waitFor(() => expect(result.current.instance).toBe(defaultInstance)); @@ -437,7 +478,8 @@ describe('useViewModelInstanceAsync - onInit', () => { const mockRiveFile = createMockRiveFile({ defaultViewModel }); const { result } = renderHook(() => - useViewModelInstanceAsync(mockRiveFile, { + useViewModelInstance(mockRiveFile, { + async: true, onInit: () => { throw new Error('init boom'); }, @@ -451,14 +493,14 @@ describe('useViewModelInstanceAsync - onInit', () => { }); }); -describe('useViewModelInstanceAsync - disposal', () => { +describe('useViewModelInstance async - 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) + useViewModelInstance(mockRiveFile, { async: true }) ); await waitFor(() => expect(result.current.instance).toBe(defaultInstance)); @@ -478,7 +520,7 @@ describe('useViewModelInstanceAsync - disposal', () => { const mockRiveFile = createMockRiveFile({ defaultViewModel }); const { unmount } = renderHook(() => - useViewModelInstanceAsync(mockRiveFile) + useViewModelInstance(mockRiveFile, { async: true }) ); unmount(); @@ -502,7 +544,8 @@ describe('useViewModelInstanceAsync - disposal', () => { }); const { result, rerender } = renderHook( - ({ file }: { file: RiveFile }) => useViewModelInstanceAsync(file), + ({ file }: { file: RiveFile }) => + useViewModelInstance(file, { async: true }), { initialProps: { file: fileA } } ); @@ -518,7 +561,7 @@ describe('useViewModelInstanceAsync - disposal', () => { }); }); -describe('useViewModelInstanceAsync - required', () => { +describe('useViewModelInstance async - required', () => { class ErrorBoundary extends React.Component< { onError: (e: Error) => void; children?: React.ReactNode }, { hasError: boolean } @@ -536,7 +579,8 @@ describe('useViewModelInstanceAsync - required', () => { } function Probe({ source }: { source: RiveFile }) { - useViewModelInstanceAsync(source, { + useViewModelInstance(source, { + async: true, viewModelName: 'NonExistent', required: true, }); diff --git a/src/hooks/useRive.ts b/src/hooks/useRive.ts index 1a3d187c..fd3cb559 100644 --- a/src/hooks/useRive.ts +++ b/src/hooks/useRive.ts @@ -5,7 +5,7 @@ export function useRive() { const riveRef = useRef(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 + // useViewModelInstance({async: true})) 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 diff --git a/src/hooks/useViewModelInstance.ts b/src/hooks/useViewModelInstance.ts index 6d9257d8..baac3784 100644 --- a/src/hooks/useViewModelInstance.ts +++ b/src/hooks/useViewModelInstance.ts @@ -7,8 +7,18 @@ import type { RiveViewRef } from '../index'; import { callDispose } from '../core/callDispose'; import { ArtboardByName } from '../specs/ArtboardBy'; import { useDisposableMemo } from './useDisposableMemo'; +import { useViewModelInstanceAsync } from './useViewModelInstanceAsync'; interface UseViewModelInstanceBaseParams { + /** + * Create the instance via the async runtime APIs (off the JS thread). + * While creation is in flight the hook reports `isLoading: true` with an + * `undefined` instance. Will become the default in the next major. + * + * Must stay constant for the lifetime of the component — it selects + * between two hook implementations. + */ + async?: boolean; /** * If true, throws an error when the instance cannot be obtained. * This is useful with Error Boundaries and ensures TypeScript knows @@ -16,8 +26,9 @@ interface UseViewModelInstanceBaseParams { */ required?: boolean; /** - * Called synchronously when a new instance is created, before the hook returns. - * Use this to set initial values that need to be available immediately. + * Called when a new instance is created, before the hook exposes it — + * synchronously during render without `async: true`, or right after the + * instance resolves (before it is published) with `async: true`. * Note: This callback is excluded from deps - changing it won't recreate the instance. */ onInit?: (instance: ViewModelInstance) => void; @@ -193,130 +204,169 @@ function createInstance( } export type UseViewModelInstanceResult = - | { instance: ViewModelInstance; error: null } - | { instance: null; error: Error } - | { instance: null; error: null } - | { instance: undefined; error: null }; + | { 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 useViewModelInstance} 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. + */ +export type UseViewModelInstanceRequiredResult = + | { instance: ViewModelInstance; isLoading: false; error: null } + | { instance: undefined; isLoading: true; error: null }; /** * 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. + * Pass `async: true` to create the instance via the async runtime APIs, + * resolving off the JS thread. The instance is then not available on the + * first render — guard on the result: + * + * ```tsx + * const { riveFile, error: fileError } = useRiveFile(require('./animation.riv')); + * const { instance, isLoading, error } = useViewModelInstance(riveFile, { async: true }); + * if (fileError || error) return ; + * if (isLoading || !instance) return ; + * // ... + * + * ``` + * + * Without `async: true` (deprecated) the instance is created synchronously + * during render via deprecated runtime APIs that block the JS thread. The + * async path will become the default in the next major. + * + * With `async: true`, a `null` source settles to a terminal + * `{ instance: null, isLoading: false }` while an `undefined` source keeps + * the hook loading. 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. * * @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) + * @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 } = useViewModelInstance(riveFile); + * const { instance, isLoading } = useViewModelInstance(riveFile, { async: true }); * ``` * * @example * ```tsx * // From RiveFile with specific instance name - * const { riveFile } = useRiveFile(require('./animation.riv')); - * const { instance } = useViewModelInstance(riveFile, { instanceName: 'PersonInstance' }); + * const { instance } = useViewModelInstance(riveFile, { async: true, instanceName: 'PersonInstance' }); * ``` * * @example * ```tsx * // From RiveFile with specific ViewModel name - * const { riveFile } = useRiveFile(require('./animation.riv')); - * const { instance } = useViewModelInstance(riveFile, { viewModelName: 'Settings' }); + * const { instance } = useViewModelInstance(riveFile, { async: true, viewModelName: 'Settings' }); * ``` * * @example * ```tsx * // From RiveFile with specific artboard - * const { riveFile } = useRiveFile(require('./animation.riv')); - * const { instance } = useViewModelInstance(riveFile, { artboardName: 'MainArtboard' }); + * const { instance } = useViewModelInstance(riveFile, { async: true, artboardName: 'MainArtboard' }); * ``` * * @example * ```tsx - * // From RiveViewRef (get auto-bound instance) + * // From RiveViewRef (waits for the view's auto-bound instance) * const { riveViewRef, setHybridRef } = useRive(); - * const { instance } = useViewModelInstance(riveViewRef); - * ``` - * - * @example - * ```tsx - * // From ViewModel - * const viewModel = file.viewModelByName('main'); - * const { instance } = useViewModelInstance(viewModel); + * const { instance } = useViewModelInstance(riveViewRef, { async: true }); * ``` * * @example * ```tsx * // Create a new blank instance from ViewModel - * const viewModel = file.viewModelByName('TodoItem'); - * const { instance } = useViewModelInstance(viewModel, { useNew: true }); + * const { instance } = useViewModelInstance(viewModel, { async: true, useNew: true }); * ``` * * @example * ```tsx - * // With required: true (throws if null, use with Error Boundary) - * const { instance } = useViewModelInstance(riveFile, { required: true }); - * // instance is guaranteed to be non-null here + * // 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 } = useViewModelInstance(riveFile, { async: true, required: true }); * ``` * * @example * ```tsx - * // With onInit to set initial values synchronously + * // With onInit to set initial values before the instance is exposed or bound * const { instance } = useViewModelInstance(riveFile, { + * async: true, * onInit: (vmi) => { - * vmi.numberProperty('count').set(initialCount); - * vmi.stringProperty('name').set(userName); + * vmi.numberProperty('count')?.set(initialCount); * } * }); * ``` - * - * @example - * ```tsx - * // Error handling - * const { instance, error } = useViewModelInstance(riveFile, { viewModelName: 'Missing' }); - * if (error) console.error(error.message); - * ``` */ // RiveFile overloads export function useViewModelInstance( source: RiveFile, - params: UseViewModelInstanceFileParams & { required: true } -): - | { instance: ViewModelInstance; error: null } - | { instance: undefined; error: null }; + params: UseViewModelInstanceFileParams & { async: true; required: true } +): UseViewModelInstanceRequiredResult; export function useViewModelInstance( source: RiveFile | null | undefined, - params?: UseViewModelInstanceFileParams + params: UseViewModelInstanceFileParams & { async: true } +): UseViewModelInstanceResult; +/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. */ +export function useViewModelInstance( + source: RiveFile, + params: UseViewModelInstanceFileParams & { async?: false; required: true } +): UseViewModelInstanceRequiredResult; +/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. */ +export function useViewModelInstance( + source: RiveFile | null | undefined, + params?: UseViewModelInstanceFileParams & { async?: false } ): UseViewModelInstanceResult; // ViewModel overloads export function useViewModelInstance( source: ViewModel, - params: UseViewModelInstanceViewModelParams & { required: true } -): - | { instance: ViewModelInstance; error: null } - | { instance: undefined; error: null }; + params: UseViewModelInstanceViewModelParams & { async: true; required: true } +): UseViewModelInstanceRequiredResult; +export function useViewModelInstance( + source: ViewModel | null | undefined, + params: UseViewModelInstanceViewModelParams & { async: true } +): UseViewModelInstanceResult; +/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. */ +export function useViewModelInstance( + source: ViewModel, + params: UseViewModelInstanceViewModelParams & { + async?: false; + required: true; + } +): UseViewModelInstanceRequiredResult; +/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. */ export function useViewModelInstance( source: ViewModel | null | undefined, - params?: UseViewModelInstanceViewModelParams + params?: UseViewModelInstanceViewModelParams & { async?: false } ): UseViewModelInstanceResult; // RiveViewRef overloads export function useViewModelInstance( source: RiveViewRef, - params: UseViewModelInstanceRefParams & { required: true } -): - | { instance: ViewModelInstance; error: null } - | { instance: undefined; error: null }; + params: UseViewModelInstanceRefParams & { async: true; required: true } +): UseViewModelInstanceRequiredResult; export function useViewModelInstance( source: RiveViewRef | null | undefined, - params?: UseViewModelInstanceRefParams + params: UseViewModelInstanceRefParams & { async: true } +): UseViewModelInstanceResult; +/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. */ +export function useViewModelInstance( + source: RiveViewRef, + params: UseViewModelInstanceRefParams & { async?: false; required: true } +): UseViewModelInstanceRequiredResult; +/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. */ +export function useViewModelInstance( + source: RiveViewRef | null | undefined, + params?: UseViewModelInstanceRefParams & { async?: false } ): UseViewModelInstanceResult; // Implementation @@ -326,6 +376,28 @@ export function useViewModelInstance( | UseViewModelInstanceFileParams | UseViewModelInstanceViewModelParams | UseViewModelInstanceRefParams +): UseViewModelInstanceResult { + const isAsync = params?.async ?? false; + // The flag selects between two hook implementations (different hook + // orders), so it must stay constant for the lifetime of the component — + // documented on the param. + if (isAsync) { + // eslint-disable-next-line react-hooks/rules-of-hooks + return useViewModelInstanceAsync( + source as RiveFile | null | undefined, + params as UseViewModelInstanceFileParams + ); + } + // eslint-disable-next-line react-hooks/rules-of-hooks + return useViewModelInstanceSync(source, params); +} + +function useViewModelInstanceSync( + source: ViewModelSource | null | undefined, + params?: + | UseViewModelInstanceFileParams + | UseViewModelInstanceViewModelParams + | UseViewModelInstanceRefParams ): UseViewModelInstanceResult { const fileInstanceName = (params as { instanceName?: string } | undefined) ?.instanceName; @@ -381,10 +453,11 @@ export function useViewModelInstance( } if (result.instance) { - return { instance: result.instance, error: null }; + return { instance: result.instance, isLoading: false, error: null }; } if (result.instance === undefined) { - return { instance: undefined, error: null }; + // Source not resolved yet (e.g. the file is still loading). + return { instance: undefined, isLoading: true, error: null }; } - return { instance: null, error }; + return { instance: null, isLoading: false, error }; } diff --git a/src/hooks/useViewModelInstanceAsync.ts b/src/hooks/useViewModelInstanceAsync.ts index 6bef0ce4..e11391ee 100644 --- a/src/hooks/useViewModelInstanceAsync.ts +++ b/src/hooks/useViewModelInstanceAsync.ts @@ -198,8 +198,9 @@ const LOADING_RESULT: UseViewModelInstanceAsyncResult = { }; /** - * Async version of {@link useViewModelInstance}. Creates a ViewModelInstance - * using the non-deprecated `*Async` runtime APIs, resolving off the JS thread. + * Implementation behind `useViewModelInstance(source, { async: true })` — not + * exported publicly. 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: diff --git a/src/index.tsx b/src/index.tsx index 9280fa52..0da565bc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -65,14 +65,11 @@ 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 + // eslint-disable-next-line @typescript-eslint/no-deprecated -- only the non-async overloads are deprecated; the export itself is current useViewModelInstance, type UseViewModelInstanceResult, + type UseViewModelInstanceRequiredResult, } 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 d3b17e3d8af610f9e1b73578b62babb0e23bef53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 9 Jul 2026 06:00:40 +0200 Subject: [PATCH 10/19] fix(legacy): run *Async file/ViewModel lookups on the main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy backends implemented the *Async lookup APIs as Promise.async { sync impl }, which moves Rive runtime access onto Nitro's background pool (Swift concurrency executor / Dispatchers. Default) — concurrent with the attached views rendering the same RiveFile on the main thread, with no synchronization in the legacy runtime. That is the race class TSan confirmed for issue #297, and useViewModelInstance({ async: true }) routes all instance setup through these paths. iOS already main-hopped instance creation (Promise.onMain in HybridViewModel); this applies the same policy to the file/ViewModel lookups and to Android, where creation also ran on the pool. Android uses a shared, never-cancelled main scope: a Promise launched on a cancelled scope would never settle, so a lookup racing dispose() must still resolve (the bodies null-guard the disposed file). On the legacy backend "async" thus means "doesn't block the JS thread", never "parallel with rendering". Not covered here (same race class, pre-existing via useRiveList/useRiveProperty): the legacy list-property operations and iOS legacy property setters still run on the pool — follow-up. --- .../com/margelo/nitro/rive/HybridRiveFile.kt | 17 +++++++++++------ .../com/margelo/nitro/rive/HybridViewModel.kt | 12 +++++++----- .../com/margelo/nitro/rive/RiveMainScope.kt | 13 +++++++++++++ ios/legacy/HybridRiveFile.swift | 15 ++++++++++----- 4 files changed, 41 insertions(+), 16 deletions(-) create mode 100644 android/src/legacy/java/com/margelo/nitro/rive/RiveMainScope.kt diff --git a/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveFile.kt b/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveFile.kt index 2fdefa69..7f91d70a 100644 --- a/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveFile.kt +++ b/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveFile.kt @@ -86,8 +86,13 @@ class HybridRiveFile : HybridRiveFileSpec() { } } + // The *Async lookups run on the main thread (via [riveMainScope], not Nitro's + // Dispatchers.Default pool): they walk the same RiveFile the attached views + // render on the main thread, and the legacy runtime has no internal + // synchronization — off-main access is the race class of issue #297. + // "Async" here means "doesn't block the JS thread". override fun getViewModelNamesAsync(): Promise> { - return Promise.async { + return Promise.async(riveMainScope) { val file = riveFile ?: return@async emptyArray() val count = file.viewModelCount val names = mutableListOf() @@ -103,19 +108,19 @@ class HybridRiveFile : HybridRiveFileSpec() { } override fun viewModelByNameAsync(name: String, validate: Boolean?): Promise { - return Promise.async { viewModelByName(name) } + return Promise.async(riveMainScope) { viewModelByName(name) } } override fun defaultArtboardViewModelAsync(artboardBy: ArtboardBy?): Promise { - return Promise.async { defaultArtboardViewModel(artboardBy) } + return Promise.async(riveMainScope) { defaultArtboardViewModel(artboardBy) } } override fun getArtboardCountAsync(): Promise { - return Promise.async { artboardCount } + return Promise.async(riveMainScope) { artboardCount } } override fun getArtboardNamesAsync(): Promise> { - return Promise.async { artboardNames } + return Promise.async(riveMainScope) { artboardNames } } override fun updateReferencedAssets(referencedAssets: ReferencedAssetsType) { @@ -140,7 +145,7 @@ class HybridRiveFile : HybridRiveFileSpec() { override fun getEnums(): Promise> { val file = riveFile ?: return Promise.resolved(emptyArray()) - return Promise.async { + return Promise.async(riveMainScope) { try { file.enums .map { enum -> diff --git a/android/src/legacy/java/com/margelo/nitro/rive/HybridViewModel.kt b/android/src/legacy/java/com/margelo/nitro/rive/HybridViewModel.kt index 906eeab9..81313033 100644 --- a/android/src/legacy/java/com/margelo/nitro/rive/HybridViewModel.kt +++ b/android/src/legacy/java/com/margelo/nitro/rive/HybridViewModel.kt @@ -57,23 +57,25 @@ class HybridViewModel(private val viewModel: ViewModel) : HybridViewModelSpec() return Promise.rejected(UnsupportedOperationException("getPropertiesAsync is not supported on the legacy backend")) } + // Main-hopped like HybridRiveFile's lookups — the legacy runtime is only + // safe to touch on the main thread (see riveMainScope). override fun getPropertyCountAsync(): Promise { - return Promise.async { propertyCount } + return Promise.async(riveMainScope) { propertyCount } } override fun getInstanceCountAsync(): Promise { - return Promise.async { instanceCount } + return Promise.async(riveMainScope) { instanceCount } } override fun createInstanceByNameAsync(name: String): Promise { - return Promise.async { createInstanceByName(name) } + return Promise.async(riveMainScope) { createInstanceByName(name) } } override fun createDefaultInstanceAsync(): Promise { - return Promise.async { createDefaultInstance() } + return Promise.async(riveMainScope) { createDefaultInstance() } } override fun createBlankInstanceAsync(): Promise { - return Promise.async { createInstance() } + return Promise.async(riveMainScope) { createInstance() } } } diff --git a/android/src/legacy/java/com/margelo/nitro/rive/RiveMainScope.kt b/android/src/legacy/java/com/margelo/nitro/rive/RiveMainScope.kt new file mode 100644 index 00000000..44c07dc9 --- /dev/null +++ b/android/src/legacy/java/com/margelo/nitro/rive/RiveMainScope.kt @@ -0,0 +1,13 @@ +package com.margelo.nitro.rive + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +// Legacy runtime objects are only safe to touch on the main thread: the +// attached views render there and rive-android's legacy API has no internal +// synchronization (off-main access is the race class of issue #297). This +// scope hops *Async calls onto main. It is intentionally never cancelled — +// a Promise launched on a cancelled scope would never settle, so bodies +// guard on disposed state instead. +internal val riveMainScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) diff --git a/ios/legacy/HybridRiveFile.swift b/ios/legacy/HybridRiveFile.swift index a47af2de..fd2495be 100644 --- a/ios/legacy/HybridRiveFile.swift +++ b/ios/legacy/HybridRiveFile.swift @@ -90,8 +90,13 @@ class HybridRiveFile: HybridRiveFileSpec, RiveViewSource { return HybridBindableArtboard(bindableArtboard: bindable) } + // The *Async lookups run on the main thread (not Nitro's pool): they walk + // the same RiveFile the attached views render on the main thread, and the + // legacy runtime has no internal synchronization — off-main access is the + // race class of issue #297. "Async" here means "doesn't block the JS + // thread", matching HybridViewModel's create*InstanceAsync. func getViewModelNamesAsync() throws -> Promise<[String]> { - return Promise.async { + return Promise.onMain { guard let file = self.riveFile else { return [] } let count = file.viewModelCount var names: [String] = [] @@ -105,19 +110,19 @@ class HybridRiveFile: HybridRiveFileSpec, RiveViewSource { } func viewModelByNameAsync(name: String, validate: Bool?) throws -> Promise<(any HybridViewModelSpec)?> { - return Promise.async { try self.viewModelByName(name: name) } + return Promise.onMain { try self.viewModelByName(name: name) } } func defaultArtboardViewModelAsync(artboardBy: ArtboardBy?) throws -> Promise<(any HybridViewModelSpec)?> { - return Promise.async { try self.defaultArtboardViewModel(artboardBy: artboardBy) } + return Promise.onMain { try self.defaultArtboardViewModel(artboardBy: artboardBy) } } func getArtboardCountAsync() throws -> Promise { - return Promise.async { self.artboardCount } + return Promise.onMain { self.artboardCount } } func getArtboardNamesAsync() throws -> Promise<[String]> { - return Promise.async { self.artboardNames } + return Promise.onMain { self.artboardNames } } func updateReferencedAssets(referencedAssets: ReferencedAssetsType) { From dec621d6c0ee1c424928996020c1719369829e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 9 Jul 2026 22:52:45 +0200 Subject: [PATCH 11/19] fix(hooks): reset during render on source change; main-hop legacy Android getViewModelInstance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from the second cold review: - The loading reset moved from the effect into render (adjust-state- during-render): the effect-time reset let React commit one frame pairing the new source with the previous instance at isLoading: false — a mismatch consumer guards can't catch (e.g. reaching native) — and then disposed that instance while still committed. New unit test captures committed frames on a source flip and fails on the old behavior (26 mismatched frames). - Legacy Android's HybridRiveView.getViewModelInstance() read controller.stateMachines on the JS thread while the main thread mutates it during startup (issue #297 race class; iOS already hops via MainThread.run). Now reads under runBlocking(Dispatchers.Main.immediate). No deterministic test is possible for the race; the ref-source e2e tests exercise this path on the android-legacy CI job. --- .../com/margelo/nitro/rive/HybridRiveView.kt | 9 +++- .../useViewModelInstance.async.test.tsx | 49 +++++++++++++++++++ src/hooks/useViewModelInstanceAsync.ts | 23 +++++++-- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveView.kt b/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveView.kt index f9e975fe..f3a98be6 100644 --- a/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveView.kt +++ b/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveView.kt @@ -12,6 +12,7 @@ import app.rive.runtime.kotlin.core.Alignment as RiveAlignment import app.rive.runtime.kotlin.core.errors.* import android.util.Log import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext fun Variant_HybridViewModelInstanceSpec_DataBindMode_DataBindByName?.toBindData(): BindData { @@ -115,7 +116,13 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() { } override fun getViewModelInstance(): HybridViewModelInstanceSpec? { - val viewModelInstance = view.getViewModelInstance() ?: return null + // Read on the main thread: the controller's state-machine list is mutated + // there during startup and the legacy runtime has no internal + // synchronization (issue #297 race class). Mirrors iOS's MainThread.run; + // `immediate` avoids a deadlock when already called on main. + val viewModelInstance = + runBlocking(Dispatchers.Main.immediate) { view.getViewModelInstance() } + ?: return null return HybridViewModelInstance(viewModelInstance) } diff --git a/src/hooks/__tests__/useViewModelInstance.async.test.tsx b/src/hooks/__tests__/useViewModelInstance.async.test.tsx index 3827159d..31175bcc 100644 --- a/src/hooks/__tests__/useViewModelInstance.async.test.tsx +++ b/src/hooks/__tests__/useViewModelInstance.async.test.tsx @@ -533,6 +533,55 @@ describe('useViewModelInstance async - disposal', () => { expect(defaultInstance.dispose).toHaveBeenCalled(); }); + it('never commits a frame pairing the new source with the old instance', async () => { + // On source change the reset must happen during render, not in the + // effect: otherwise React commits one frame of + // (isLoading false, so + // consumer guards can't catch it) and then disposes instanceA while it + // is still the committed dataBind. + const instanceA = createMockViewModelInstance('A'); + const fileA = createMockRiveFile({ + defaultViewModel: createMockViewModel({ defaultInstance: instanceA }), + }); + const instanceB = createMockViewModelInstance('B'); + const fileB = createMockRiveFile({ + defaultViewModel: createMockViewModel({ defaultInstance: instanceB }), + }); + + const frames: Array<{ + file: RiveFile; + instance: ViewModelInstance | null | undefined; + isLoading: boolean; + }> = []; + function Probe({ file }: { file: RiveFile }) { + const { instance, isLoading } = useViewModelInstance(file, { + async: true, + }); + React.useEffect(() => { + frames.push({ file, instance, isLoading }); + }); + return null; + } + + const view = render(); + await waitFor(() => + expect(frames.some((f) => f.instance === instanceA)).toBe(true) + ); + + await act(async () => { + view.rerender(); + await Promise.resolve(); + }); + await waitFor(() => + expect(frames.some((f) => f.instance === instanceB)).toBe(true) + ); + + const mismatched = frames.filter( + (f) => f.file === fileB && f.instance === instanceA + ); + expect(mismatched).toEqual([]); + }); + it('disposes the previous instance when the source changes', async () => { const instanceA = createMockViewModelInstance('A'); const fileA = createMockRiveFile({ diff --git a/src/hooks/useViewModelInstanceAsync.ts b/src/hooks/useViewModelInstanceAsync.ts index e11391ee..72a0488a 100644 --- a/src/hooks/useViewModelInstanceAsync.ts +++ b/src/hooks/useViewModelInstanceAsync.ts @@ -331,6 +331,25 @@ export function useViewModelInstanceAsync( const [result, setResult] = useState(LOADING_RESULT); + // Reset to the loading state during render (not in the effect) when the + // inputs change: an effect-time reset would let React commit one frame + // pairing the new source with the previous (about-to-be-disposed) instance + // at isLoading: false — a mismatch consumer guards cannot catch, and the + // old instance would be disposed while still committed as e.g. a view's + // dataBind. Resetting mid-render makes React re-render before committing. + const [prevDeps, setPrevDeps] = useState([ + source, + instanceName, + artboardName, + viewModelName, + useNew, + ]); + const deps = [source, instanceName, artboardName, viewModelName, useNew]; + if (deps.some((d, i) => d !== prevDeps[i])) { + setPrevDeps(deps); + setResult((prev) => (prev.isLoading ? prev : LOADING_RESULT)); + } + useEffect(() => { if (source === null) { // Source resolved to absent/failed rather than pending. `useRiveFile` @@ -342,10 +361,6 @@ export function useViewModelInstanceAsync( 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; From 8430822aa531f7c90a1ea890757ab377147604ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 9 Jul 2026 22:57:08 +0200 Subject: [PATCH 12/19] fix(hooks): accept widened async params; terminal null source on the sync path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more items from the second cold review: - Params objects built separately widen async to boolean (and the exported *Params types declare async?: boolean), matching no literal overload — the compiler rejected the call blaming the *source* argument, and values of the hook's own exported param types stopped being accepted. New catch-all overloads (per source type) restore compatibility; literal async values still resolve to the specific overloads first, keeping deprecation warnings and required narrowing. Type-level regression tests gate via yarn tsc. - The sync (non-async) path labeled a null source isLoading: true forever — the exact spinner-hang the async docs warn about. It now settles to a terminal { instance: null, isLoading: false } and, with required: true, throws — mirroring the async path. Behavior change on the deprecated path, in the direction of the documented contract. --- .../useViewModelInstance.async.test.tsx | 39 ++++++++++++++++++- .../__tests__/useViewModelInstance.test.ts | 15 ++++++- src/hooks/useViewModelInstance.ts | 35 ++++++++++++++++- 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/hooks/__tests__/useViewModelInstance.async.test.tsx b/src/hooks/__tests__/useViewModelInstance.async.test.tsx index 31175bcc..3c08c23b 100644 --- a/src/hooks/__tests__/useViewModelInstance.async.test.tsx +++ b/src/hooks/__tests__/useViewModelInstance.async.test.tsx @@ -5,7 +5,10 @@ import { waitFor, act, } from '@testing-library/react-native'; -import { useViewModelInstance } from '../useViewModelInstance'; +import { + useViewModelInstance, + type UseViewModelInstanceFileParams, +} from '../useViewModelInstance'; import type { RiveFile } from '../../specs/RiveFile.nitro'; import type { ViewModel, ViewModelInstance } from '../../specs/ViewModel.nitro'; import type { ArtboardBy } from '../../specs/ArtboardBy'; @@ -361,6 +364,40 @@ function createMockRiveViewRef( return { getViewModelInstance: jest.fn(getViewModelInstance) } as any; } +describe('useViewModelInstance - param type compatibility', () => { + // These pin overload resolution as much as runtime behavior: a params + // object built separately widens `async` to boolean, and the exported + // params types declare `async?: boolean` — both must remain accepted + // (`yarn tsc` is the gate that fails when the overloads regress). + it('accepts a params object with a widened boolean async', async () => { + const defaultInstance = createMockViewModelInstance(); + const defaultViewModel = createMockViewModel({ defaultInstance }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const params = { async: true }; // widens to { async: boolean } + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, params) + ); + + await waitFor(() => expect(result.current.instance).toBe(defaultInstance)); + }); + + it('accepts a value of the exported file-params type with a nullable source', async () => { + const defaultInstance = createMockViewModelInstance(); + const defaultViewModel = createMockViewModel({ defaultInstance }); + const mockRiveFile: RiveFile | null | undefined = createMockRiveFile({ + defaultViewModel, + }); + + const params: UseViewModelInstanceFileParams = { async: true }; + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, params) + ); + + await waitFor(() => expect(result.current.instance).toBe(defaultInstance)); + }); +}); + describe('useViewModelInstance async - RiveViewRef source', () => { it('resolves the view-bound instance', async () => { const vmi = createMockViewModelInstance(); diff --git a/src/hooks/__tests__/useViewModelInstance.test.ts b/src/hooks/__tests__/useViewModelInstance.test.ts index 6b8680e3..b8f3aa55 100644 --- a/src/hooks/__tests__/useViewModelInstance.test.ts +++ b/src/hooks/__tests__/useViewModelInstance.test.ts @@ -363,11 +363,22 @@ describe('useViewModelInstance - ViewModel source', () => { }); }); -describe('useViewModelInstance - null source', () => { - it('should return undefined instance when source is null', () => { +describe('useViewModelInstance - null vs undefined source', () => { + it('settles to a terminal null when the source is null (e.g. file load failed)', () => { + // Mirrors the async path: `useRiveFile` returns null on error and + // undefined while loading — a null source must not read as isLoading. const { result } = renderHook(() => useViewModelInstance(null)); + expect(result.current.instance).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('reports isLoading while the source is undefined (still resolving)', () => { + const { result } = renderHook(() => useViewModelInstance(undefined)); + expect(result.current.instance).toBeUndefined(); + expect(result.current.isLoading).toBe(true); expect(result.current.error).toBeNull(); }); }); diff --git a/src/hooks/useViewModelInstance.ts b/src/hooks/useViewModelInstance.ts index baac3784..1857a557 100644 --- a/src/hooks/useViewModelInstance.ts +++ b/src/hooks/useViewModelInstance.ts @@ -238,7 +238,7 @@ export type UseViewModelInstanceRequiredResult = * during render via deprecated runtime APIs that block the JS thread. The * async path will become the default in the next major. * - * With `async: true`, a `null` source settles to a terminal + * In both modes, a `null` source settles to a terminal * `{ instance: null, isLoading: false }` while an `undefined` source keeps * the hook loading. This mirrors {@link useRiveFile} (`riveFile: undefined` * while loading, `null` on error) and `useRive` (`riveViewRef: undefined` @@ -369,6 +369,25 @@ export function useViewModelInstance( params?: UseViewModelInstanceRefParams & { async?: false } ): UseViewModelInstanceResult; +// Widened-`async` catch-alls: a params object built separately (or typed +// with the exported *Params types) carries `async?: boolean` and matches no +// literal overload above — without these the compiler rejects the call and +// blames the *source* argument. The flag still must stay constant for the +// component's lifetime. Literal `async` values resolve to the more specific +// overloads first, keeping the deprecation warnings and `required` narrowing. +export function useViewModelInstance( + source: RiveFile | null | undefined, + params?: UseViewModelInstanceFileParams +): UseViewModelInstanceResult; +export function useViewModelInstance( + source: ViewModel | null | undefined, + params?: UseViewModelInstanceViewModelParams +): UseViewModelInstanceResult; +export function useViewModelInstance( + source: RiveViewRef | null | undefined, + params?: UseViewModelInstanceRefParams +): UseViewModelInstanceResult; + // Implementation export function useViewModelInstance( source: ViewModelSource | null | undefined, @@ -443,6 +462,20 @@ function useViewModelInstanceSync( [result.error] ); + if (result.instance === undefined && source === null) { + // Source resolved to absent/failed rather than pending (`useRiveFile` + // returns null on load error, undefined while loading) — settle to a + // terminal null instead of reporting isLoading forever, mirroring the + // async path. + if (required) { + throw new Error( + 'useViewModelInstance: Failed to get ViewModelInstance. ' + + 'Ensure the source has a valid ViewModel and instance available.' + ); + } + return { instance: null, isLoading: false, error: null }; + } + if (required && result.instance === null) { throw new Error( result.error From 8859f66d2e4f38c63ae75be38ac2e413fd535b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 9 Jul 2026 23:03:40 +0200 Subject: [PATCH 13/19] fix(hooks): actionable messages, guarded async flip, specific native logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remaining small items from the second cold review: - required-mode throws no longer leak the internal hook name (useViewModelInstanceAsync: → useViewModelInstance:), and a null source gets its own message pointing at the upstream hook's error instead of 'ensure the source has a valid ViewModel' - flipping the async param between renders now logs an actionable console.error before React's cryptic hooks-order invariant fires - the VM-less normalization on the new backends logs what it swallows (RiveLog.d) and propagates cancellation instead of eating it - e2e: expectDefined also rejects null (its NonNullable claim was unsound), the resolve-to-null test pins the stable 'not found' message, and android-experimental skips announce themselves - JSDoc: the required example now states that required-narrowing needs a non-nullable source type (nullable sources resolve to the standard overload; the runtime throw still applies) --- .../com/margelo/nitro/rive/HybridRiveFile.kt | 8 +- ...useViewModelInstance-async-e2e.harness.tsx | 20 ++++- ios/new/HybridRiveFile.swift | 13 +++- .../useViewModelInstance.async.test.tsx | 74 ++++++++++++++++++- src/hooks/useViewModelInstance.ts | 20 ++++- src/hooks/useViewModelInstanceAsync.ts | 12 ++- 6 files changed, 134 insertions(+), 13 deletions(-) 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 79112a2c..0338c008 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridRiveFile.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridRiveFile.kt @@ -103,10 +103,16 @@ class HybridRiveFile( // 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. + // documented "no ViewModel" result — but propagate cancellation and log + // what was swallowed so a genuine failure (disposed file, dead command + // queue) isn't a silently blank screen. val vmInfo = try { file.getDefaultViewModelInfo(artboard) + } catch (e: kotlinx.coroutines.CancellationException) { + runCatching { artboard.close() } + throw e } catch (e: Exception) { + RiveLog.d(TAG, "defaultArtboardViewModel: resolving null — getDefaultViewModelInfo failed: ${e.message}") runCatching { artboard.close() } return null } diff --git a/example/__tests__/useViewModelInstance-async-e2e.harness.tsx b/example/__tests__/useViewModelInstance-async-e2e.harness.tsx index c72ba839..50e235c4 100644 --- a/example/__tests__/useViewModelInstance-async-e2e.harness.tsx +++ b/example/__tests__/useViewModelInstance-async-e2e.harness.tsx @@ -32,7 +32,10 @@ const NO_DEFAULT_VM = require('../assets/rive/nodefaultbouncing.riv'); const BOUNCING_BALL = require('../assets/rive/bouncing_ball.riv'); function expectDefined(value: T): asserts value is NonNullable { + // toBeDefined() alone passes for null, which would betray the NonNullable + // type claim this helper makes. expect(value).toBeDefined(); + expect(value).not.toBeNull(); } async function loadFile() { @@ -348,6 +351,9 @@ describe('useViewModelInstance async: instance creation failure', () => { await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 5000 }); expect(ctx.instance).toBeNull(); expectDefined(ctx.error); + // The stable message, not a leaked raw native diagnostic. + expect(ctx.error.message).toContain('not found'); + expect(ctx.error.message).not.toContain('Could not create'); cleanup(); }); }); @@ -415,7 +421,12 @@ describe('useViewModelInstance async: RiveViewRef source', () => { const isAndroidExperimental = Platform.OS === 'android' && RiveFileFactory.getBackend() === 'experimental'; - if (isAndroidExperimental) return; + if (isAndroidExperimental) { + console.warn( + 'SKIP: android-experimental — getViewModelInstance() does not expose the auto-bound VMI yet' + ); + return; + } const file = await RiveFileFactory.fromSource(BOUNCING_BALL, undefined); const ctx = createCtx(); @@ -430,7 +441,12 @@ describe('useViewModelInstance async: RiveViewRef source', () => { const isAndroidExperimental = Platform.OS === 'android' && RiveFileFactory.getBackend() === 'experimental'; - if (isAndroidExperimental) return; + if (isAndroidExperimental) { + console.warn( + 'SKIP: android-experimental — getViewModelInstance() does not expose the auto-bound VMI yet' + ); + return; + } const file = await RiveFileFactory.fromSource(BOUNCING_BALL, undefined); const ctx = createCtx(); diff --git a/ios/new/HybridRiveFile.swift b/ios/new/HybridRiveFile.swift index f62948c3..204c97a5 100644 --- a/ios/new/HybridRiveFile.swift +++ b/ios/new/HybridRiveFile.swift @@ -92,11 +92,18 @@ class HybridRiveFile: HybridRiveFileSpec { // 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 { + // documented "no ViewModel" result — but propagate cancellation and log + // what was swallowed so a genuine failure (disposed file, dead worker) + // isn't a silently blank screen. + do { + let vmInfo = try await file.getDefaultViewModelInfo(for: artboard) + return HybridViewModel(file: file, vmName: vmInfo.viewModelName, worker: worker) + } catch is CancellationError { + throw CancellationError() + } catch { + RiveLog.d("RiveFile", "defaultArtboardViewModel: resolving nil — getDefaultViewModelInfo failed: \(error)") return nil } - return HybridViewModel(file: file, vmName: vmInfo.viewModelName, worker: worker) } // Deprecated: Use defaultArtboardViewModelAsync instead diff --git a/src/hooks/__tests__/useViewModelInstance.async.test.tsx b/src/hooks/__tests__/useViewModelInstance.async.test.tsx index 3c08c23b..091cb246 100644 --- a/src/hooks/__tests__/useViewModelInstance.async.test.tsx +++ b/src/hooks/__tests__/useViewModelInstance.async.test.tsx @@ -687,9 +687,79 @@ describe('useViewModelInstance async - required', () => { ); await waitFor(() => expect(onError).toHaveBeenCalled()); - expect((onError.mock.calls[0][0] as Error).message).toContain( - 'NonExistent' + const message = (onError.mock.calls[0][0] as Error).message; + expect(message).toContain('NonExistent'); + // Users called useViewModelInstance — the internal hook name must not leak. + expect(message).toContain('useViewModelInstance:'); + expect(message).not.toContain('useViewModelInstanceAsync'); + consoleError.mockRestore(); + }); + + it('names the absent source (not the ViewModel) when required throws for a null source', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const onError = jest.fn(); + + function NullSourceProbe() { + useViewModelInstance(null as RiveFile | null, { + async: true, + required: true, + }); + return null; + } + + render( + + + ); + + await waitFor(() => expect(onError).toHaveBeenCalled()); + const message = (onError.mock.calls[0][0] as Error).message; + // A null source means the file/view failed upstream — pointing users at + // "Ensure the source has a valid ViewModel" sends them the wrong way. + expect(message).toContain('useViewModelInstance:'); + expect(message).toMatch(/source is null/i); + expect(message).toMatch(/useRiveFile|useRive/); + consoleError.mockRestore(); + }); +}); + +describe('useViewModelInstance - async flag constancy guard', () => { + it('reports an actionable error when async changes between renders', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const defaultInstance = createMockViewModelInstance(); + // The initial render takes the sync path, so the mock must speak the + // sync API too (the shared factories only stub the *Async surface). + const mockRiveFile = { + ...createMockRiveFile({ + defaultViewModel: createMockViewModel({ defaultInstance }), + }), + defaultArtboardViewModel: jest.fn(() => ({ + dispose: jest.fn(), + createDefaultInstance: jest.fn(() => defaultInstance), + createInstanceByName: jest.fn(), + createInstance: jest.fn(), + })), + } as unknown as RiveFile; + + const { rerender } = renderHook( + ({ isAsync }: { isAsync: boolean }) => + useViewModelInstance(mockRiveFile, { async: isAsync }), + { initialProps: { isAsync: false } } + ); + + // Flipping the flag switches hook implementations — React will throw its + // generic hooks-order invariant; the hook must first explain why. + expect(() => rerender({ isAsync: true })).toThrow(); + expect( + consoleError.mock.calls.some((c) => + String(c[0]).includes('`async` param changed between renders') + ) + ).toBe(true); consoleError.mockRestore(); }); }); diff --git a/src/hooks/useViewModelInstance.ts b/src/hooks/useViewModelInstance.ts index 1857a557..c9342464 100644 --- a/src/hooks/useViewModelInstance.ts +++ b/src/hooks/useViewModelInstance.ts @@ -292,6 +292,9 @@ export type UseViewModelInstanceRequiredResult = * ```tsx * // With required: true (throws once resolved to null, use with Error Boundary). * // Note: instance is still `undefined` while loading — guard on isLoading. + * // The required-narrowed return type applies only when the source's TYPE is + * // non-nullable; with a nullable source (e.g. straight from useRiveFile) the + * // call resolves to the standard overload — the runtime throw still applies. * const { instance, isLoading } = useViewModelInstance(riveFile, { async: true, required: true }); * ``` * @@ -399,7 +402,18 @@ export function useViewModelInstance( const isAsync = params?.async ?? false; // The flag selects between two hook implementations (different hook // orders), so it must stay constant for the lifetime of the component — - // documented on the param. + // documented on the param. React's own failure for a flip is the cryptic + // "Rendered more hooks than during the previous render"; explain first. + const initialAsyncRef = useRef(isAsync); + if (initialAsyncRef.current !== isAsync) { + console.error( + 'useViewModelInstance: the `async` param changed between renders ' + + `(${String(initialAsyncRef.current)} → ${String(isAsync)}). It selects ` + + 'between two hook implementations, so it must stay constant for the ' + + 'lifetime of the component; remount (e.g. change `key`) to switch modes.' + ); + initialAsyncRef.current = isAsync; + } if (isAsync) { // eslint-disable-next-line react-hooks/rules-of-hooks return useViewModelInstanceAsync( @@ -469,8 +483,8 @@ function useViewModelInstanceSync( // async path. if (required) { throw new Error( - 'useViewModelInstance: Failed to get ViewModelInstance. ' + - 'Ensure the source has a valid ViewModel and instance available.' + 'useViewModelInstance: source is null — the file or view failed to ' + + "resolve upstream (if it comes from useRiveFile or useRive, check that hook's error)." ); } return { instance: null, isLoading: false, error: null }; diff --git a/src/hooks/useViewModelInstanceAsync.ts b/src/hooks/useViewModelInstanceAsync.ts index 72a0488a..160ed695 100644 --- a/src/hooks/useViewModelInstanceAsync.ts +++ b/src/hooks/useViewModelInstanceAsync.ts @@ -425,10 +425,18 @@ export function useViewModelInstanceAsync( }, [source, instanceName, artboardName, viewModelName, useNew]); if (required && result.instance === null && !result.isLoading) { + // The public entry point is useViewModelInstance — don't leak this + // internal hook's name into user-facing errors. + if (source === null) { + throw new Error( + 'useViewModelInstance: source is null — the file or view failed to ' + + "resolve upstream (if it comes from useRiveFile or useRive, check that hook's error)." + ); + } throw new Error( result.error - ? `useViewModelInstanceAsync: ${result.error.message}` - : 'useViewModelInstanceAsync: Failed to get ViewModelInstance. ' + + ? `useViewModelInstance: ${result.error.message}` + : 'useViewModelInstance: Failed to get ViewModelInstance. ' + 'Ensure the source has a valid ViewModel and instance available.' ); } From 8e82ea0a79a06db3361b6b18c9fa17515f917f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 10 Jul 2026 00:00:46 +0200 Subject: [PATCH 14/19] fix(ci+android): restore the full harness suite; non-blocking legacy VMI getter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - expo57-example/jest.config.js scoped its roots to ../example/src, which silently dropped ~20 on-device suites (all of example/__tests__, including this PR's e2e guards) from every harness job. Widen the root to ../example so the full shared suite runs against the Expo app, per the config's own stated intent. - Replace the legacy Android getViewModelInstance main-hop (runBlocking(Dispatchers.Main.immediate), added earlier on this branch) with a main-thread-maintained @Volatile snapshot: off-main callers get the last snapshot and schedule a refresh — eventually consistent, which suits the polling consumers (the async hook's ref path, harness waitFor loops). runBlocking JS→main can deadlock if the main thread ever waits on JS under the legacy bridge, and is the prime suspect in the android-legacy harness job timing out on all three attempts; the hazard is real regardless of that verdict. --- .../com/margelo/nitro/rive/HybridRiveView.kt | 11 +++------ .../java/com/rive/RiveReactNativeView.kt | 23 ++++++++++++++++++- expo57-example/jest.config.js | 8 ++++--- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveView.kt b/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveView.kt index f3a98be6..318ad099 100644 --- a/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveView.kt +++ b/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveView.kt @@ -12,7 +12,6 @@ import app.rive.runtime.kotlin.core.Alignment as RiveAlignment import app.rive.runtime.kotlin.core.errors.* import android.util.Log import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext fun Variant_HybridViewModelInstanceSpec_DataBindMode_DataBindByName?.toBindData(): BindData { @@ -116,13 +115,9 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() { } override fun getViewModelInstance(): HybridViewModelInstanceSpec? { - // Read on the main thread: the controller's state-machine list is mutated - // there during startup and the legacy runtime has no internal - // synchronization (issue #297 race class). Mirrors iOS's MainThread.run; - // `immediate` avoids a deadlock when already called on main. - val viewModelInstance = - runBlocking(Dispatchers.Main.immediate) { view.getViewModelInstance() } - ?: return null + // Thread-safety lives in RiveReactNativeView.getViewModelInstance(): it + // returns a main-thread-maintained snapshot without blocking this thread. + val viewModelInstance = view.getViewModelInstance() ?: return null return HybridViewModelInstance(viewModelInstance) } diff --git a/android/src/legacy/java/com/rive/RiveReactNativeView.kt b/android/src/legacy/java/com/rive/RiveReactNativeView.kt index dd812908..c2fe7cdc 100644 --- a/android/src/legacy/java/com/rive/RiveReactNativeView.kt +++ b/android/src/legacy/java/com/rive/RiveReactNativeView.kt @@ -167,7 +167,17 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { } } - fun getViewModelInstance(): ViewModelInstance? { + // Cache maintained on the main thread: the controller's state-machine list + // is mutated there and the legacy runtime has no internal synchronization + // (issue #297 race class), so other threads must not traverse it. Off-main + // reads return the last main-thread snapshot and schedule a refresh — + // eventually consistent, which suits the polling callers (the async hook's + // ref path, harness waitFor loops). Blocking on the main thread instead + // would risk a JS↔main deadlock under the legacy bridge. + @Volatile + private var lastKnownViewModelInstance: ViewModelInstance? = null + + private fun readViewModelInstanceOnMain(): ViewModelInstance? { val stateMachines = riveAnimationView?.controller?.stateMachines return if (!stateMachines.isNullOrEmpty()) { stateMachines.first().viewModelInstance @@ -176,6 +186,17 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { } } + fun getViewModelInstance(): ViewModelInstance? { + if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) { + lastKnownViewModelInstance = readViewModelInstanceOnMain() + return lastKnownViewModelInstance + } + android.os.Handler(android.os.Looper.getMainLooper()).post { + lastKnownViewModelInstance = readViewModelInstanceOnMain() + } + return lastKnownViewModelInstance + } + fun applyDataBinding(bindData: BindData) { bindToStateMachine(bindData) } diff --git a/expo57-example/jest.config.js b/expo57-example/jest.config.js index 94a97992..11af4b93 100644 --- a/expo57-example/jest.config.js +++ b/expo57-example/jest.config.js @@ -1,6 +1,8 @@ module.exports = { preset: 'react-native-harness', - // Run the bare example's harness suite against this Expo app; the shared - // metro config redirects those files' imports into this workspace. - roots: ['', '/../example/src'], + // Run the bare example's FULL harness suite against this Expo app (both + // example/__tests__ and example/src/__tests__); the shared metro config + // redirects those files' imports and assets into this workspace. Scoping + // this to example/src silently dropped ~20 on-device suites from CI. + roots: ['', '/../example'], }; From 7677a563d91b5a1dd57852632c1cb76bcc1fe21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 10 Jul 2026 00:41:11 +0200 Subject: [PATCH 15/19] ci: resize harness attempt budgets for the restored full suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-attempt watchdogs (iOS 300s x 5, Android timeout 300 x 3) were sized for the 4-suite subset that ran while expo57's jest roots excluded example/__tests__. The restored ~25-suite run gets through about 15 suites before the 300s kill lands (exit 137, mid reload-rebind on the observed runs — position, not culprit), so every attempt dies the same way. iOS: 3 attempts x 600s (step timeout 15m -> 35m); Android: timeout 600, job timeout 30m -> 50m. --- .github/workflows/ci.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db69efa5..4c863de3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -369,7 +369,7 @@ jobs: - name: Run harness tests on iOS working-directory: expo57-example - timeout-minutes: 15 + timeout-minutes: 35 shell: bash --noprofile --norc -o pipefail {0} env: # First attempt bundles through the harness Metro on app boot; CI @@ -433,12 +433,12 @@ jobs: BOOTED_UDID=$(xcrun simctl list devices booted | grep -oE '[0-9A-F-]{36}' | head -1) echo "booted simulator: ${BOOTED_UDID:-none}" - for attempt in 1 2 3 4 5; do - echo "=== Attempt $attempt of 5 ===" + for attempt in 1 2 3; do + echo "=== Attempt $attempt of 3 ===" start_monitor yarn test:harness:ios --verbose --testTimeout 120000 & test_pid=$! - ( sleep 300; echo " WATCHDOG: attempt $attempt exceeded 300s, killing harness"; kill -9 $test_pid 2>/dev/null ) & + ( sleep 600; echo " WATCHDOG: attempt $attempt exceeded 600s, killing harness"; kill -9 $test_pid 2>/dev/null ) & watchdog_pid=$! wait $test_pid 2>/dev/null exit_code=$? @@ -506,7 +506,7 @@ jobs: test-harness-android: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 50 env: ANDROID_API_LEVEL: 35 steps: @@ -595,7 +595,7 @@ jobs: script: | adb install expo57-example/android/app/build/outputs/apk/debug/app-debug.apk sleep 10 - cd expo57-example && for attempt in 1 2 3; do echo "Attempt $attempt of 3"; if timeout 300 env ANDROID_AVD=test yarn test:harness:android --verbose --testTimeout 120000; then echo "Tests passed on attempt $attempt"; exit 0; fi; echo "Attempt $attempt failed (exit $?), retrying..."; sleep 5; done; echo "All attempts failed"; exit 1 + cd expo57-example && for attempt in 1 2 3; do echo "Attempt $attempt of 3"; if timeout 600 env ANDROID_AVD=test yarn test:harness:android --verbose --testTimeout 120000; then echo "Tests passed on attempt $attempt"; exit 0; fi; echo "Attempt $attempt failed (exit $?), retrying..."; sleep 5; done; echo "All attempts failed"; exit 1 - name: Debug - Check logcat if: failure() || cancelled() @@ -703,7 +703,7 @@ jobs: - name: Run harness tests on iOS working-directory: expo57-example - timeout-minutes: 15 + timeout-minutes: 35 shell: bash --noprofile --norc -o pipefail {0} env: # First attempt bundles through the harness Metro on app boot; CI @@ -767,12 +767,12 @@ jobs: BOOTED_UDID=$(xcrun simctl list devices booted | grep -oE '[0-9A-F-]{36}' | head -1) echo "booted simulator: ${BOOTED_UDID:-none}" - for attempt in 1 2 3 4 5; do - echo "=== Attempt $attempt of 5 ===" + for attempt in 1 2 3; do + echo "=== Attempt $attempt of 3 ===" start_monitor yarn test:harness:ios --verbose --testTimeout 120000 & test_pid=$! - ( sleep 300; echo " WATCHDOG: attempt $attempt exceeded 300s, killing harness"; kill -9 $test_pid 2>/dev/null ) & + ( sleep 600; echo " WATCHDOG: attempt $attempt exceeded 600s, killing harness"; kill -9 $test_pid 2>/dev/null ) & watchdog_pid=$! wait $test_pid 2>/dev/null exit_code=$? @@ -840,7 +840,7 @@ jobs: test-harness-android-legacy: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 50 env: ANDROID_API_LEVEL: 35 steps: @@ -936,7 +936,7 @@ jobs: script: | adb install expo57-example/android/app/build/outputs/apk/debug/app-debug.apk sleep 10 - cd expo57-example && for attempt in 1 2 3; do echo "Attempt $attempt of 3"; if timeout 300 env ANDROID_AVD=test yarn test:harness:android --verbose --testTimeout 120000; then echo "Tests passed on attempt $attempt"; exit 0; fi; echo "Attempt $attempt failed (exit $?), retrying..."; sleep 5; done; echo "All attempts failed"; exit 1 + cd expo57-example && for attempt in 1 2 3; do echo "Attempt $attempt of 3"; if timeout 600 env ANDROID_AVD=test yarn test:harness:android --verbose --testTimeout 120000; then echo "Tests passed on attempt $attempt"; exit 0; fi; echo "Attempt $attempt failed (exit $?), retrying..."; sleep 5; done; echo "All attempts failed"; exit 1 - name: Debug - Check logcat if: failure() || cancelled() From cf99b1ddfdcff2da9a0d6fb770b4234d48d3df90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 10 Jul 2026 02:39:48 +0200 Subject: [PATCH 16/19] fix(android-legacy): refresh the VMI snapshot eagerly when binding changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot getter's posted-refresh design broke the read-after-bind contract: autoplay-false-trigger's issue #156 guard binds via the dataBind prop and does a one-shot getViewModelInstance() read, which returned the stale (null) snapshot on android-legacy (iOS still reads synchronously). Binding always happens on the main thread, so refresh the snapshot right where it changes (bindViewModelInstance and bindToStateMachine) — a JS read after binding then sees a fresh value with no blocking and no polling needed. --- android/src/legacy/java/com/rive/RiveReactNativeView.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/android/src/legacy/java/com/rive/RiveReactNativeView.kt b/android/src/legacy/java/com/rive/RiveReactNativeView.kt index c2fe7cdc..5b9244f2 100644 --- a/android/src/legacy/java/com/rive/RiveReactNativeView.kt +++ b/android/src/legacy/java/com/rive/RiveReactNativeView.kt @@ -165,6 +165,10 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { if (!stateMachines.isNullOrEmpty()) { stateMachines.first().viewModelInstance = vmi } + // Keep the snapshot fresh at the moment binding changes so a JS-side + // read right after binding sees the instance without waiting for a + // posted refresh (guards the read-after-bind contract, issue #156). + lastKnownViewModelInstance = readViewModelInstanceOnMain() } // Cache maintained on the main thread: the controller's state-machine list @@ -381,6 +385,9 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { } } } + // Keep the snapshot fresh at the moment binding changes — see + // lastKnownViewModelInstance. + lastKnownViewModelInstance = readViewModelInstanceOnMain() } private fun convertEventProperties(properties: Map?): Map? { From 80912d01fabd6301ccd20a4f9a67cab98d61c926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 10 Jul 2026 08:46:28 +0200 Subject: [PATCH 17/19] refactor(hooks): route widened async params to the deprecated overloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the non-deprecated catch-all overloads with plain deprecated overloads (no async?: false pin): a params object whose async widened to boolean — or a value of the exported *Params types — now resolves to the deprecated overload instead of a warning-free catch-all. Since async is a member of the params types, such calls compile (no weak-type failure) and behave per the runtime value; the deprecation message tells the caller the fix: re-pin with { ...params, async: true }. This makes deprecation coverage total — any call not provably async: true is flagged — at the cost of a nudge for already-migrated callers who kept a widened object, which the message resolves. The type-compat tests keep pinning that these calls compile and run async at runtime, now with intentional no-deprecated disables. --- .../useViewModelInstance.async.test.tsx | 9 +++- src/hooks/useViewModelInstance.ts | 44 +++++-------------- 2 files changed, 18 insertions(+), 35 deletions(-) diff --git a/src/hooks/__tests__/useViewModelInstance.async.test.tsx b/src/hooks/__tests__/useViewModelInstance.async.test.tsx index 091cb246..1c81ed25 100644 --- a/src/hooks/__tests__/useViewModelInstance.async.test.tsx +++ b/src/hooks/__tests__/useViewModelInstance.async.test.tsx @@ -367,8 +367,10 @@ function createMockRiveViewRef( describe('useViewModelInstance - param type compatibility', () => { // These pin overload resolution as much as runtime behavior: a params // object built separately widens `async` to boolean, and the exported - // params types declare `async?: boolean` — both must remain accepted - // (`yarn tsc` is the gate that fails when the overloads regress). + // params types declare `async?: boolean` — both must keep COMPILING and + // behave per the runtime value (`yarn tsc` is the gate). By design they + // resolve to the deprecated overloads: the warning's message tells such + // callers to re-pin with `{ ...params, async: true }`. it('accepts a params object with a widened boolean async', async () => { const defaultInstance = createMockViewModelInstance(); const defaultViewModel = createMockViewModel({ defaultInstance }); @@ -376,6 +378,7 @@ describe('useViewModelInstance - param type compatibility', () => { const params = { async: true }; // widens to { async: boolean } const { result } = renderHook(() => + // eslint-disable-next-line @typescript-eslint/no-deprecated -- widened async resolves to the deprecated overload by design; runtime still honors the value useViewModelInstance(mockRiveFile, params) ); @@ -391,6 +394,7 @@ describe('useViewModelInstance - param type compatibility', () => { const params: UseViewModelInstanceFileParams = { async: true }; const { result } = renderHook(() => + // eslint-disable-next-line @typescript-eslint/no-deprecated -- exported param types carry async?: boolean and resolve to the deprecated overload by design useViewModelInstance(mockRiveFile, params) ); @@ -748,6 +752,7 @@ describe('useViewModelInstance - async flag constancy guard', () => { const { rerender } = renderHook( ({ isAsync }: { isAsync: boolean }) => + // eslint-disable-next-line @typescript-eslint/no-deprecated -- dynamic async is the misuse under test useViewModelInstance(mockRiveFile, { async: isAsync }), { initialProps: { isAsync: false } } ); diff --git a/src/hooks/useViewModelInstance.ts b/src/hooks/useViewModelInstance.ts index c9342464..b05adb5e 100644 --- a/src/hooks/useViewModelInstance.ts +++ b/src/hooks/useViewModelInstance.ts @@ -318,15 +318,15 @@ export function useViewModelInstance( source: RiveFile | null | undefined, params: UseViewModelInstanceFileParams & { async: true } ): UseViewModelInstanceResult; -/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. */ +/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. If your params object's `async` widened to `boolean`, re-pin it at the call site: `{ ...params, async: true }`. */ export function useViewModelInstance( source: RiveFile, - params: UseViewModelInstanceFileParams & { async?: false; required: true } + params: UseViewModelInstanceFileParams & { required: true } ): UseViewModelInstanceRequiredResult; -/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. */ +/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. If your params object's `async` widened to `boolean`, re-pin it at the call site: `{ ...params, async: true }`. */ export function useViewModelInstance( source: RiveFile | null | undefined, - params?: UseViewModelInstanceFileParams & { async?: false } + params?: UseViewModelInstanceFileParams ): UseViewModelInstanceResult; // ViewModel overloads @@ -338,18 +338,15 @@ export function useViewModelInstance( source: ViewModel | null | undefined, params: UseViewModelInstanceViewModelParams & { async: true } ): UseViewModelInstanceResult; -/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. */ +/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. If your params object's `async` widened to `boolean`, re-pin it at the call site: `{ ...params, async: true }`. */ export function useViewModelInstance( source: ViewModel, - params: UseViewModelInstanceViewModelParams & { - async?: false; - required: true; - } + params: UseViewModelInstanceViewModelParams & { required: true } ): UseViewModelInstanceRequiredResult; -/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. */ +/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. If your params object's `async` widened to `boolean`, re-pin it at the call site: `{ ...params, async: true }`. */ export function useViewModelInstance( source: ViewModel | null | undefined, - params?: UseViewModelInstanceViewModelParams & { async?: false } + params?: UseViewModelInstanceViewModelParams ): UseViewModelInstanceResult; // RiveViewRef overloads @@ -361,31 +358,12 @@ export function useViewModelInstance( source: RiveViewRef | null | undefined, params: UseViewModelInstanceRefParams & { async: true } ): UseViewModelInstanceResult; -/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. */ +/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. If your params object's `async` widened to `boolean`, re-pin it at the call site: `{ ...params, async: true }`. */ export function useViewModelInstance( source: RiveViewRef, - params: UseViewModelInstanceRefParams & { async?: false; required: true } + params: UseViewModelInstanceRefParams & { required: true } ): UseViewModelInstanceRequiredResult; -/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. */ -export function useViewModelInstance( - source: RiveViewRef | null | undefined, - params?: UseViewModelInstanceRefParams & { async?: false } -): UseViewModelInstanceResult; - -// Widened-`async` catch-alls: a params object built separately (or typed -// with the exported *Params types) carries `async?: boolean` and matches no -// literal overload above — without these the compiler rejects the call and -// blames the *source* argument. The flag still must stay constant for the -// component's lifetime. Literal `async` values resolve to the more specific -// overloads first, keeping the deprecation warnings and `required` narrowing. -export function useViewModelInstance( - source: RiveFile | null | undefined, - params?: UseViewModelInstanceFileParams -): UseViewModelInstanceResult; -export function useViewModelInstance( - source: ViewModel | null | undefined, - params?: UseViewModelInstanceViewModelParams -): UseViewModelInstanceResult; +/** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. If your params object's `async` widened to `boolean`, re-pin it at the call site: `{ ...params, async: true }`. */ export function useViewModelInstance( source: RiveViewRef | null | undefined, params?: UseViewModelInstanceRefParams From 8b8c6f231f4693867e986229b51b423f34b00b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 10 Jul 2026 08:51:52 +0200 Subject: [PATCH 18/19] test(hooks): type-level pins for overload deprecation resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dedicated typetest file asserts which calls resolve to @deprecated overloads, in both directions: calls that must be deprecated carry a no-deprecated disable, and eslint runs typetest files with reportUnusedDisableDirectives: 'error' — so if the resolution ever changes, the now-unused directive fails lint; calls that must stay non-deprecated are written bare and fail no-deprecated directly on regression. @ts-expect-error pins the invalid param combinations, and return-type annotations pin the required-narrowing behavior. Gated by yarn lint + yarn tsc (the jest body is a vessel; jest matches all of __tests__). --- eslint.config.mjs | 9 ++ ...ViewModelInstance.deprecation.typetest.tsx | 109 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 src/hooks/__tests__/useViewModelInstance.deprecation.typetest.tsx diff --git a/eslint.config.mjs b/eslint.config.mjs index 3d48cba7..edab4242 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -49,6 +49,15 @@ export default defineConfig([ '@typescript-eslint/no-deprecated': 'error', }, }, + { + // Type-test files pin overload/deprecation resolution: a + // no-deprecated disable there asserts "this call IS deprecated", so an + // unused directive means the resolution regressed and must fail lint. + files: ['**/*.typetest.{ts,tsx}'], + linterOptions: { + reportUnusedDisableDirectives: 'error', + }, + }, { ignores: [ 'node_modules/', diff --git a/src/hooks/__tests__/useViewModelInstance.deprecation.typetest.tsx b/src/hooks/__tests__/useViewModelInstance.deprecation.typetest.tsx new file mode 100644 index 00000000..c5f46b6e --- /dev/null +++ b/src/hooks/__tests__/useViewModelInstance.deprecation.typetest.tsx @@ -0,0 +1,109 @@ +/** + * Type-level pins for useViewModelInstance's overload resolution. + * + * This file is a lint/compile target, not a runtime suite: eslint runs it + * with `reportUnusedDisableDirectives: 'error'` (see eslint.config.mjs), so + * - calls that MUST resolve to a @deprecated overload carry a + * `eslint-disable-next-line @typescript-eslint/no-deprecated` — if the + * resolution ever changes, the directive becomes unused and lint fails; + * - calls that MUST stay non-deprecated are written bare — if they ever + * resolve to a deprecated overload, `no-deprecated` fails directly; + * - `@ts-expect-error` pins invalid combinations at the compiler level. + */ +import { + useViewModelInstance, + type UseViewModelInstanceFileParams, + type UseViewModelInstanceRequiredResult, + type UseViewModelInstanceResult, +} from '../useViewModelInstance'; +import type { RiveFile } from '../../specs/RiveFile.nitro'; +import type { ViewModel } from '../../specs/ViewModel.nitro'; +import type { RiveViewRef } from '../../index'; + +export function useDeprecationResolutionPins( + file: RiveFile, + vm: ViewModel, + ref: RiveViewRef +) { + // ── must be DEPRECATED (directive required; unused directive = lint error) + // eslint-disable-next-line @typescript-eslint/no-deprecated + const bare = useViewModelInstance(file); + // eslint-disable-next-line @typescript-eslint/no-deprecated + const noAsync = useViewModelInstance(file, { instanceName: 'X' }); + // eslint-disable-next-line @typescript-eslint/no-deprecated + const asyncFalse = useViewModelInstance(file, { async: false }); + + const widened = { async: true }; // widens to { async: boolean } + // eslint-disable-next-line @typescript-eslint/no-deprecated + const fromWidened = useViewModelInstance(file, widened); + + const viaExportedType: UseViewModelInstanceFileParams = { async: true }; + // eslint-disable-next-line @typescript-eslint/no-deprecated + const fromExportedType = useViewModelInstance(file, viaExportedType); + + // eslint-disable-next-line @typescript-eslint/no-deprecated + const vmSource = useViewModelInstance(vm, { name: 'X' }); + // eslint-disable-next-line @typescript-eslint/no-deprecated + const refSource = useViewModelInstance(ref); + + // ── must stay NON-deprecated (bare calls; no-deprecated fails on regress) + const asyncFile = useViewModelInstance(file, { async: true }); + const asyncArtboard = useViewModelInstance(file, { + async: true, + artboardName: 'Main', + }); + const asyncVm = useViewModelInstance(vm, { async: true, useNew: true }); + const asyncRef = useViewModelInstance(ref, { async: true }); + // The documented fix for widened params re-pins the literal at the call: + const repinned = useViewModelInstance(file, { + ...viaExportedType, + async: true, + }); + + // ── required narrowing only with a literal async on a non-null source + const req: UseViewModelInstanceRequiredResult = useViewModelInstance(file, { + async: true, + required: true, + }); + // Widened params lose the narrowing (resolve to the plain deprecated + // overload) but must still compile: + // eslint-disable-next-line @typescript-eslint/no-deprecated + const reqWidened: UseViewModelInstanceResult = useViewModelInstance( + file, + viaExportedType + ); + + // ── invalid combinations must not compile + // @ts-expect-error artboardName and viewModelName are mutually exclusive + const invalid = useViewModelInstance(file, { + async: true, + artboardName: 'A', + viewModelName: 'B', + }); + + return { + bare, + noAsync, + asyncFalse, + fromWidened, + fromExportedType, + vmSource, + refSource, + asyncFile, + asyncArtboard, + asyncVm, + asyncRef, + repinned, + req, + reqWidened, + invalid, + }; +} + +// jest picks up every file under __tests__ — the real gates for this file +// are `yarn tsc` and `yarn lint` (see header). +describe('useViewModelInstance deprecation type pins', () => { + it('is enforced by tsc and eslint, not at runtime', () => { + expect(typeof useDeprecationResolutionPins).toBe('function'); + }); +}); From dae84b06708188dff1e20eee243054b5e4729a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 10 Jul 2026 09:49:15 +0200 Subject: [PATCH 19/19] test(hooks): migrate deprecation pins to tsd, matching PR #294's wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the session-invented eslint typetest convention with the tsd infrastructure PR #294 introduces, copied as closely as possible so whichever PR merges second reconciles trivially: tsd ^0.33.0, the 'typetest' script (tsd --typings src/index.tsx), the package.json tsd block (directory src/__tests__, paths/moduleResolution/jsx options), jest testPathIgnorePatterns for *.test-d.ts, eslint + root-tsconfig excludes for *.test-d.ts. The pins live in src/__tests__/useViewModelInstance.test-d.ts as explicit assertions: expectDeprecated for every call that must resolve to a deprecated overload (bare, async: false, widened boolean async, exported param types), expectNotDeprecated for the literal async: true forms including the { ...params, async: true } re-pin, expectType for the required-narrowing behavior, and expectError for invalid param combinations. Verified that tsd resolves deprecation per-overload for call expressions (failure messages name the resolved signature). One deliberate divergence from #294: CI gates 'yarn typetest' in the lint job (theirs wires the script without a CI step) — keep the step when the branches meet. --- .github/workflows/ci.yml | 5 + eslint.config.mjs | 12 +- package.json | 22 +- src/__tests__/useViewModelInstance.test-d.ts | 93 +++++++ ...ViewModelInstance.deprecation.typetest.tsx | 109 -------- tsconfig.json | 2 +- yarn.lock | 242 +++++++++++++++++- 7 files changed, 357 insertions(+), 128 deletions(-) create mode 100644 src/__tests__/useViewModelInstance.test-d.ts delete mode 100644 src/hooks/__tests__/useViewModelInstance.deprecation.typetest.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c863de3..ed5a5586 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,11 @@ jobs: - name: Typecheck files run: yarn typecheck + # Overload/deprecation pins (src/__tests__/*.test-d.ts). PR #294 adds + # the same tsd wiring without gating it; keep this step when merging. + - name: Type tests + run: yarn typetest + test: runs-on: ubuntu-latest steps: diff --git a/eslint.config.mjs b/eslint.config.mjs index edab4242..66f18495 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -49,21 +49,15 @@ export default defineConfig([ '@typescript-eslint/no-deprecated': 'error', }, }, - { - // Type-test files pin overload/deprecation resolution: a - // no-deprecated disable there asserts "this call IS deprecated", so an - // unused directive means the resolution regressed and must fail lint. - files: ['**/*.typetest.{ts,tsx}'], - linterOptions: { - reportUnusedDisableDirectives: 'error', - }, - }, { ignores: [ 'node_modules/', 'lib/', '**/.expo/', '**/.harness/', + // tsd owns *.test-d.ts (yarn typetest) — deprecated calls there are + // intentional expectDeprecated() pins, not violations. + '**/*.test-d.ts', // Agent worktrees (.claude/worktrees/*) are gitignored full-repo copies; // linting them duplicates every finding and slows the run. '**/.claude/', diff --git a/package.json b/package.json index 5e712861..1621d0a6 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "lint:swift": "./scripts/lint-swift.sh", "lint:kotlin": "./scripts/lint-kotlin.sh", "lint:fix:kotlin": "./scripts/lint-fix-kotlin.sh", - "lint:native": "yarn lint:swift && yarn lint:kotlin" + "lint:native": "yarn lint:swift && yarn lint:kotlin", + "typetest": "tsd --typings src/index.tsx" }, "keywords": [ "react-native", @@ -101,6 +102,7 @@ "react-native-nitro-modules": "0.35.10", "react-test-renderer": "19.0.0", "release-it": "^17.10.0", + "tsd": "^0.33.0", "turbo": "^1.10.7", "typescript": "^5.2.2" }, @@ -126,6 +128,10 @@ "/src/**/__tests__/**/*.{js,jsx,ts,tsx}", "/src/**/*.{spec,test}.{js,jsx,ts,tsx}" ], + "testPathIgnorePatterns": [ + "/node_modules/", + "\\.test-d\\.ts$" + ], "modulePathIgnorePatterns": [ "/example/node_modules", "/lib/" @@ -191,6 +197,20 @@ ] ] }, + "tsd": { + "typings": "src/index.tsx", + "directory": "src/__tests__", + "compilerOptions": { + "paths": { + "@rive-app/react-native": ["./src/index"] + }, + "moduleResolution": "bundler", + "jsx": "react-jsx", + "lib": ["ESNext"], + "strict": true, + "allowArbitraryExtensions": true + } + }, "create-react-native-library": { "languages": "kotlin-swift", "type": "nitro-module", diff --git a/src/__tests__/useViewModelInstance.test-d.ts b/src/__tests__/useViewModelInstance.test-d.ts new file mode 100644 index 00000000..9d973688 --- /dev/null +++ b/src/__tests__/useViewModelInstance.test-d.ts @@ -0,0 +1,93 @@ +/** + * tsd pins for useViewModelInstance's overload resolution (`yarn typetest`). + * + * - expectDeprecated: the call must resolve to a @deprecated overload + * (any call not provably `async: true` — bare, async: false, widened + * params, exported param types). + * - expectNotDeprecated: literal `async: true` calls must stay clean. + * - expectError pins invalid param combinations. + * - expectType pins the `required` narrowing behavior. + */ +import { + expectDeprecated, + expectNotDeprecated, + expectError, + expectType, + expectAssignable, +} from 'tsd'; +import { useViewModelInstance } from '../index'; +import type { + UseViewModelInstanceRequiredResult, + UseViewModelInstanceResult, +} from '../index'; +import type { UseViewModelInstanceFileParams } from '../hooks/useViewModelInstance'; +import type { RiveFile } from '../specs/RiveFile.nitro'; +import type { ViewModel } from '../specs/ViewModel.nitro'; +import type { RiveViewRef } from '../index'; + +declare const file: RiveFile; +declare const nullableFile: RiveFile | null | undefined; +declare const vm: ViewModel; +declare const ref: RiveViewRef; + +// ── must resolve to a @deprecated overload ─────────────────────────── +expectDeprecated(useViewModelInstance(file)); +expectDeprecated(useViewModelInstance(nullableFile)); +expectDeprecated(useViewModelInstance(file, { instanceName: 'X' })); +expectDeprecated(useViewModelInstance(file, { async: false })); +expectDeprecated(useViewModelInstance(vm, { name: 'X' })); +expectDeprecated(useViewModelInstance(ref)); + +// widened `async: boolean` params resolve to the deprecated overload by +// design — the deprecation message tells callers to re-pin with +// `{ ...params, async: true }` +declare const widened: { async: boolean }; +expectDeprecated(useViewModelInstance(file, widened)); + +// values of the exported param types (async?: boolean) do too +declare const viaExportedType: UseViewModelInstanceFileParams; +expectDeprecated(useViewModelInstance(file, viaExportedType)); + +// ── must stay non-deprecated ───────────────────────────────────────── +expectNotDeprecated(useViewModelInstance(file, { async: true })); +expectNotDeprecated(useViewModelInstance(nullableFile, { async: true })); +expectNotDeprecated( + useViewModelInstance(file, { async: true, artboardName: 'Main' }) +); +expectNotDeprecated( + useViewModelInstance(file, { async: true, viewModelName: 'Settings' }) +); +expectNotDeprecated(useViewModelInstance(vm, { async: true, useNew: true })); +expectNotDeprecated(useViewModelInstance(ref, { async: true })); +// the documented fix for widened params re-pins the literal at the call: +expectNotDeprecated( + useViewModelInstance(file, { ...viaExportedType, async: true }) +); + +// ── result types ───────────────────────────────────────────────────── +// required narrowing needs a literal async AND a non-nullable source +expectType( + useViewModelInstance(file, { async: true, required: true }) +); +// nullable source falls back to the standard result (runtime still throws) +expectType( + useViewModelInstance(nullableFile, { async: true, required: true }) +); +// widened params keep the standard result shape +expectType(useViewModelInstance(file, widened)); +// the required result is a subset of the standard result +expectAssignable( + useViewModelInstance(file, { async: true, required: true }) +); + +// ── invalid combinations must not compile ──────────────────────────── +// artboardName and viewModelName are mutually exclusive +expectError( + useViewModelInstance(file, { + async: true, + artboardName: 'A', + viewModelName: 'B', + }) +); +// file-source params on a ViewModel source +expectError(useViewModelInstance(vm, { async: true, artboardName: 'A' })); diff --git a/src/hooks/__tests__/useViewModelInstance.deprecation.typetest.tsx b/src/hooks/__tests__/useViewModelInstance.deprecation.typetest.tsx deleted file mode 100644 index c5f46b6e..00000000 --- a/src/hooks/__tests__/useViewModelInstance.deprecation.typetest.tsx +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Type-level pins for useViewModelInstance's overload resolution. - * - * This file is a lint/compile target, not a runtime suite: eslint runs it - * with `reportUnusedDisableDirectives: 'error'` (see eslint.config.mjs), so - * - calls that MUST resolve to a @deprecated overload carry a - * `eslint-disable-next-line @typescript-eslint/no-deprecated` — if the - * resolution ever changes, the directive becomes unused and lint fails; - * - calls that MUST stay non-deprecated are written bare — if they ever - * resolve to a deprecated overload, `no-deprecated` fails directly; - * - `@ts-expect-error` pins invalid combinations at the compiler level. - */ -import { - useViewModelInstance, - type UseViewModelInstanceFileParams, - type UseViewModelInstanceRequiredResult, - type UseViewModelInstanceResult, -} from '../useViewModelInstance'; -import type { RiveFile } from '../../specs/RiveFile.nitro'; -import type { ViewModel } from '../../specs/ViewModel.nitro'; -import type { RiveViewRef } from '../../index'; - -export function useDeprecationResolutionPins( - file: RiveFile, - vm: ViewModel, - ref: RiveViewRef -) { - // ── must be DEPRECATED (directive required; unused directive = lint error) - // eslint-disable-next-line @typescript-eslint/no-deprecated - const bare = useViewModelInstance(file); - // eslint-disable-next-line @typescript-eslint/no-deprecated - const noAsync = useViewModelInstance(file, { instanceName: 'X' }); - // eslint-disable-next-line @typescript-eslint/no-deprecated - const asyncFalse = useViewModelInstance(file, { async: false }); - - const widened = { async: true }; // widens to { async: boolean } - // eslint-disable-next-line @typescript-eslint/no-deprecated - const fromWidened = useViewModelInstance(file, widened); - - const viaExportedType: UseViewModelInstanceFileParams = { async: true }; - // eslint-disable-next-line @typescript-eslint/no-deprecated - const fromExportedType = useViewModelInstance(file, viaExportedType); - - // eslint-disable-next-line @typescript-eslint/no-deprecated - const vmSource = useViewModelInstance(vm, { name: 'X' }); - // eslint-disable-next-line @typescript-eslint/no-deprecated - const refSource = useViewModelInstance(ref); - - // ── must stay NON-deprecated (bare calls; no-deprecated fails on regress) - const asyncFile = useViewModelInstance(file, { async: true }); - const asyncArtboard = useViewModelInstance(file, { - async: true, - artboardName: 'Main', - }); - const asyncVm = useViewModelInstance(vm, { async: true, useNew: true }); - const asyncRef = useViewModelInstance(ref, { async: true }); - // The documented fix for widened params re-pins the literal at the call: - const repinned = useViewModelInstance(file, { - ...viaExportedType, - async: true, - }); - - // ── required narrowing only with a literal async on a non-null source - const req: UseViewModelInstanceRequiredResult = useViewModelInstance(file, { - async: true, - required: true, - }); - // Widened params lose the narrowing (resolve to the plain deprecated - // overload) but must still compile: - // eslint-disable-next-line @typescript-eslint/no-deprecated - const reqWidened: UseViewModelInstanceResult = useViewModelInstance( - file, - viaExportedType - ); - - // ── invalid combinations must not compile - // @ts-expect-error artboardName and viewModelName are mutually exclusive - const invalid = useViewModelInstance(file, { - async: true, - artboardName: 'A', - viewModelName: 'B', - }); - - return { - bare, - noAsync, - asyncFalse, - fromWidened, - fromExportedType, - vmSource, - refSource, - asyncFile, - asyncArtboard, - asyncVm, - asyncRef, - repinned, - req, - reqWidened, - invalid, - }; -} - -// jest picks up every file under __tests__ — the real gates for this file -// are `yarn tsc` and `yarn lint` (see header). -describe('useViewModelInstance deprecation type pins', () => { - it('is enforced by tsc and eslint, not at runtime', () => { - expect(typeof useDeprecationResolutionPins).toBe('function'); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index f45fef55..478a91ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,5 +26,5 @@ "target": "ESNext", "verbatimModuleSyntax": true }, - "exclude": ["expo-example", "expo55-example", "expo57-example"] + "exclude": ["expo-example", "expo55-example", "expo57-example", "**/*.test-d.ts"] } diff --git a/yarn.lock b/yarn.lock index 213821e8..3a1d07da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7037,6 +7037,7 @@ __metadata: react-native-nitro-modules: 0.35.10 react-test-renderer: 19.0.0 release-it: ^17.10.0 + tsd: ^0.33.0 turbo: ^1.10.7 typescript: ^5.2.2 peerDependencies: @@ -7380,6 +7381,13 @@ __metadata: languageName: node linkType: hard +"@tsd/typescript@npm:^5.9.2": + version: 5.9.3 + resolution: "@tsd/typescript@npm:5.9.3" + checksum: d1209e850cb36715b62305821f87877c11756cb0530d562f2d0c7f34feb34d6524c13d12f79f020c6bf69d8e3f5085deaef6657004937668c38f4986d82fa98f + languageName: node + linkType: hard + "@tybys/wasm-util@npm:^0.10.0": version: 0.10.1 resolution: "@tybys/wasm-util@npm:0.10.1" @@ -7463,6 +7471,23 @@ __metadata: languageName: node linkType: hard +"@types/eslint@npm:^7.2.13": + version: 7.29.0 + resolution: "@types/eslint@npm:7.29.0" + dependencies: + "@types/estree": "*" + "@types/json-schema": "*" + checksum: df13991c554954353ce8f3bb03e19da6cc71916889443d68d178d4f858b561ba4cc4a4f291c6eb9eebb7f864b12b9b9313051b3a8dfea3e513dadf3188a77bdf + languageName: node + linkType: hard + +"@types/estree@npm:*": + version: 1.0.9 + resolution: "@types/estree@npm:1.0.9" + checksum: 752c0afee3ec82b8e24484bf6a27dfa093bbf3de4ef1c20ed0364fb6ad2c0c7971e7504ed9a7aaff103a47e2d945ce7a17f74951743dd944782a0735f53170de + languageName: node + linkType: hard + "@types/estree@npm:1.0.8, @types/estree@npm:^1.0.6": version: 1.0.8 resolution: "@types/estree@npm:1.0.8" @@ -7521,7 +7546,7 @@ __metadata: languageName: node linkType: hard -"@types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.9": +"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.9": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" checksum: 97ed0cb44d4070aecea772b7b2e2ed971e10c81ec87dd4ecc160322ffa55ff330dace1793489540e3e318d90942064bb697cc0f8989391797792d919737b3b98 @@ -7535,7 +7560,7 @@ __metadata: languageName: node linkType: hard -"@types/minimist@npm:^1.2.2": +"@types/minimist@npm:^1.2.0, @types/minimist@npm:^1.2.2": version: 1.2.5 resolution: "@types/minimist@npm:1.2.5" checksum: 477047b606005058ab0263c4f58097136268007f320003c348794f74adedc3166ffc47c80ec3e94687787f2ab7f4e72c468223946e79892cf0fd9e25e9970a90 @@ -9515,6 +9540,17 @@ __metadata: languageName: node linkType: hard +"camelcase-keys@npm:^6.2.2": + version: 6.2.2 + resolution: "camelcase-keys@npm:6.2.2" + dependencies: + camelcase: ^5.3.1 + map-obj: ^4.0.0 + quick-lru: ^4.0.1 + checksum: 43c9af1adf840471e54c68ab3e5fe8a62719a6b7dbf4e2e86886b7b0ff96112c945736342b837bd2529ec9d1c7d1934e5653318478d98e0cf22c475c04658e2a + languageName: node + linkType: hard + "camelcase-keys@npm:^7.0.0": version: 7.0.2 resolution: "camelcase-keys@npm:7.0.2" @@ -11300,6 +11336,22 @@ __metadata: languageName: node linkType: hard +"eslint-formatter-pretty@npm:^4.1.0": + version: 4.1.0 + resolution: "eslint-formatter-pretty@npm:4.1.0" + dependencies: + "@types/eslint": ^7.2.13 + ansi-escapes: ^4.2.1 + chalk: ^4.1.0 + eslint-rule-docs: ^1.1.5 + log-symbols: ^4.0.0 + plur: ^4.0.0 + string-width: ^4.2.0 + supports-hyperlinks: ^2.0.0 + checksum: e8e0cd3843513fff32a70b036dd349fdab81d73b5e522f23685181c907a1faf2b2ebcae1688dc71d0fc026184011792f7e39b833d349df18fe2baea00d017901 + languageName: node + linkType: hard + "eslint-import-context@npm:^0.1.8": version: 0.1.9 resolution: "eslint-import-context@npm:0.1.9" @@ -11601,6 +11653,13 @@ __metadata: languageName: node linkType: hard +"eslint-rule-docs@npm:^1.1.5": + version: 1.1.235 + resolution: "eslint-rule-docs@npm:1.1.235" + checksum: b163596f9a05568e287b2c78f51a280092122a2e43c45fa2c200f0bd3f61877af186c641dab97620978bec96d9e2cfb621e51728044d9efe42ddc24f5a594b26 + languageName: node + linkType: hard + "eslint-scope@npm:5.1.1, eslint-scope@npm:^5.1.1": version: 5.1.1 resolution: "eslint-scope@npm:5.1.1" @@ -13994,6 +14053,13 @@ __metadata: languageName: node linkType: hard +"hosted-git-info@npm:^2.1.4": + version: 2.8.9 + resolution: "hosted-git-info@npm:2.8.9" + checksum: c955394bdab888a1e9bb10eb33029e0f7ce5a2ac7b3f158099dc8c486c99e73809dca609f5694b223920ca2174db33d32b12f9a2a47141dc59607c29da5a62dd + languageName: node + linkType: hard + "hosted-git-info@npm:^4.0.1": version: 4.1.0 resolution: "hosted-git-info@npm:4.1.0" @@ -14298,6 +14364,13 @@ __metadata: languageName: node linkType: hard +"irregular-plurals@npm:^3.2.0": + version: 3.5.0 + resolution: "irregular-plurals@npm:3.5.0" + checksum: 5b663091dc89155df7b2e9d053e8fb11941a0c4be95c4b6549ed3ea020489fdf4f75ea586c915b5b543704252679a5a6e8c6c3587da5ac3fc57b12da90a9aee7 + languageName: node + linkType: hard + "is-absolute@npm:^1.0.0": version: 1.0.0 resolution: "is-absolute@npm:1.0.0" @@ -15084,7 +15157,7 @@ __metadata: languageName: node linkType: hard -"jest-diff@npm:^29.7.0": +"jest-diff@npm:^29.0.3, jest-diff@npm:^29.7.0": version: 29.7.0 resolution: "jest-diff@npm:29.7.0" dependencies: @@ -16028,7 +16101,7 @@ __metadata: languageName: node linkType: hard -"log-symbols@npm:^4.1.0": +"log-symbols@npm:^4.0.0, log-symbols@npm:^4.1.0": version: 4.1.0 resolution: "log-symbols@npm:4.1.0" dependencies: @@ -16162,7 +16235,7 @@ __metadata: languageName: node linkType: hard -"map-obj@npm:^4.1.0": +"map-obj@npm:^4.0.0, map-obj@npm:^4.1.0": version: 4.3.0 resolution: "map-obj@npm:4.3.0" checksum: fbc554934d1a27a1910e842bc87b177b1a556609dd803747c85ece420692380827c6ae94a95cce4407c054fa0964be3bf8226f7f2cb2e9eeee432c7c1985684e @@ -16238,6 +16311,26 @@ __metadata: languageName: node linkType: hard +"meow@npm:^9.0.0": + version: 9.0.0 + resolution: "meow@npm:9.0.0" + dependencies: + "@types/minimist": ^1.2.0 + camelcase-keys: ^6.2.2 + decamelize: ^1.2.0 + decamelize-keys: ^1.1.0 + hard-rejection: ^2.1.0 + minimist-options: 4.1.0 + normalize-package-data: ^3.0.0 + read-pkg-up: ^7.0.1 + redent: ^3.0.0 + trim-newlines: ^3.0.0 + type-fest: ^0.18.0 + yargs-parser: ^20.2.3 + checksum: 99799c47247f4daeee178e3124f6ef6f84bde2ba3f37652865d5d8f8b8adcf9eedfc551dd043e2455cd8206545fd848e269c0c5ab6b594680a0ad4d3617c9639 + languageName: node + linkType: hard + "merge-options@npm:^3.0.4": version: 3.0.4 resolution: "merge-options@npm:3.0.4" @@ -17875,7 +17968,19 @@ __metadata: languageName: node linkType: hard -"normalize-package-data@npm:^3.0.2": +"normalize-package-data@npm:^2.5.0": + version: 2.5.0 + resolution: "normalize-package-data@npm:2.5.0" + dependencies: + hosted-git-info: ^2.1.4 + resolve: ^1.10.0 + semver: 2 || 3 || 4 || 5 + validate-npm-package-license: ^3.0.1 + checksum: 7999112efc35a6259bc22db460540cae06564aa65d0271e3bdfa86876d08b0e578b7b5b0028ee61b23f1cae9fc0e7847e4edc0948d3068a39a2a82853efc8499 + languageName: node + linkType: hard + +"normalize-package-data@npm:^3.0.0, normalize-package-data@npm:^3.0.2": version: 3.0.3 resolution: "normalize-package-data@npm:3.0.3" dependencies: @@ -18431,7 +18536,7 @@ __metadata: languageName: node linkType: hard -"parse-json@npm:^5.2.0": +"parse-json@npm:^5.0.0, parse-json@npm:^5.2.0": version: 5.2.0 resolution: "parse-json@npm:5.2.0" dependencies: @@ -18644,6 +18749,15 @@ __metadata: languageName: node linkType: hard +"plur@npm:^4.0.0": + version: 4.0.0 + resolution: "plur@npm:4.0.0" + dependencies: + irregular-plurals: ^3.2.0 + checksum: fea2e903efca67cc5c7a8952fca3db46ae8d9e9353373b406714977e601a5d3b628bcb043c3ad2126c6ff0e73d8020bf43af30a72dd087eff1ec240eb13b90e1 + languageName: node + linkType: hard + "pngjs@npm:^3.3.0": version: 3.4.0 resolution: "pngjs@npm:3.4.0" @@ -18969,6 +19083,13 @@ __metadata: languageName: node linkType: hard +"quick-lru@npm:^4.0.1": + version: 4.0.1 + resolution: "quick-lru@npm:4.0.1" + checksum: bea46e1abfaa07023e047d3cf1716a06172c4947886c053ede5c50321893711577cb6119360f810cc3ffcd70c4d7db4069c3cee876b358ceff8596e062bd1154 + languageName: node + linkType: hard + "quick-lru@npm:^5.1.1": version: 5.1.1 resolution: "quick-lru@npm:5.1.1" @@ -19875,6 +19996,17 @@ __metadata: languageName: node linkType: hard +"read-pkg-up@npm:^7.0.0, read-pkg-up@npm:^7.0.1": + version: 7.0.1 + resolution: "read-pkg-up@npm:7.0.1" + dependencies: + find-up: ^4.1.0 + read-pkg: ^5.2.0 + type-fest: ^0.8.1 + checksum: e4e93ce70e5905b490ca8f883eb9e48b5d3cebc6cd4527c25a0d8f3ae2903bd4121c5ab9c5a3e217ada0141098eeb661313c86fa008524b089b8ed0b7f165e44 + languageName: node + linkType: hard + "read-pkg-up@npm:^8.0.0": version: 8.0.0 resolution: "read-pkg-up@npm:8.0.0" @@ -19886,6 +20018,18 @@ __metadata: languageName: node linkType: hard +"read-pkg@npm:^5.2.0": + version: 5.2.0 + resolution: "read-pkg@npm:5.2.0" + dependencies: + "@types/normalize-package-data": ^2.4.0 + normalize-package-data: ^2.5.0 + parse-json: ^5.0.0 + type-fest: ^0.6.0 + checksum: eb696e60528b29aebe10e499ba93f44991908c57d70f2d26f369e46b8b9afc208ef11b4ba64f67630f31df8b6872129e0a8933c8c53b7b4daf0eace536901222 + languageName: node + linkType: hard + "read-pkg@npm:^6.0.0": version: 6.0.0 resolution: "read-pkg@npm:6.0.0" @@ -20193,6 +20337,20 @@ __metadata: languageName: node linkType: hard +"resolve@npm:^1.10.0": + version: 1.22.12 + resolution: "resolve@npm:1.22.12" + dependencies: + es-errors: ^1.3.0 + is-core-module: ^2.16.1 + path-parse: ^1.0.7 + supports-preserve-symlinks-flag: ^1.0.0 + bin: + resolve: bin/resolve + checksum: 4dc5a614b32142ef9ab455b242ed33c472c4ea50df17dbe1e9dac5fe0eebd7d5fdb7cb9cc8ad2165e5e0f07694498a74e7fbd6cc1599e20d84682cce1b80a4dc + languageName: node + linkType: hard + "resolve@npm:^2.0.0-next.5": version: 2.0.0-next.5 resolution: "resolve@npm:2.0.0-next.5" @@ -20228,6 +20386,20 @@ __metadata: languageName: node linkType: hard +"resolve@patch:resolve@^1.10.0#~builtin": + version: 1.22.12 + resolution: "resolve@patch:resolve@npm%3A1.22.12#~builtin::version=1.22.12&hash=c3c19d" + dependencies: + es-errors: ^1.3.0 + is-core-module: ^2.16.1 + path-parse: ^1.0.7 + supports-preserve-symlinks-flag: ^1.0.0 + bin: + resolve: bin/resolve + checksum: 0cc5b060cbe081c85c331ac2eb08e8a54f0a195b899d5001822e5d3e2b335da651b1eed3d259fea904c22a0da9324a061e0e7ceab5dbeb5bcab5250b625754e1 + languageName: node + linkType: hard + "resolve@patch:resolve@^2.0.0-next.5#~builtin": version: 2.0.0-next.5 resolution: "resolve@patch:resolve@npm%3A2.0.0-next.5#~builtin::version=2.0.0-next.5&hash=c3c19d" @@ -20519,6 +20691,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:2 || 3 || 4 || 5": + version: 5.7.2 + resolution: "semver@npm:5.7.2" + bin: + semver: bin/semver + checksum: fb4ab5e0dd1c22ce0c937ea390b4a822147a9c53dbd2a9a0132f12fe382902beef4fbf12cf51bb955248d8d15874ce8cd89532569756384f994309825f10b686 + languageName: node + linkType: hard + "semver@npm:7.6.3, semver@npm:~7.6.3": version: 7.6.3 resolution: "semver@npm:7.6.3" @@ -21601,6 +21782,13 @@ __metadata: languageName: node linkType: hard +"trim-newlines@npm:^3.0.0": + version: 3.0.1 + resolution: "trim-newlines@npm:3.0.1" + checksum: b530f3fadf78e570cf3c761fb74fef655beff6b0f84b29209bac6c9622db75ad1417f4a7b5d54c96605dcd72734ad44526fef9f396807b90839449eb543c6206 + languageName: node + linkType: hard + "trim-newlines@npm:^4.0.2": version: 4.1.1 resolution: "trim-newlines@npm:4.1.1" @@ -21664,6 +21852,23 @@ __metadata: languageName: node linkType: hard +"tsd@npm:^0.33.0": + version: 0.33.0 + resolution: "tsd@npm:0.33.0" + dependencies: + "@tsd/typescript": ^5.9.2 + eslint-formatter-pretty: ^4.1.0 + globby: ^11.0.1 + jest-diff: ^29.0.3 + meow: ^9.0.0 + path-exists: ^4.0.0 + read-pkg-up: ^7.0.0 + bin: + tsd: dist/cli.js + checksum: 2916bcfd9d46bbeaab09e28ca714d4792c64fe12eda1a31e3d0786546fe93a1d2aeba0af2a68be4f9b211d3466ecf4f0187e4dbe45fe9f3eb29c4690c98e3379 + languageName: node + linkType: hard + "tslib@npm:^1.8.1": version: 1.14.1 resolution: "tslib@npm:1.14.1" @@ -21776,6 +21981,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.18.0": + version: 0.18.1 + resolution: "type-fest@npm:0.18.1" + checksum: e96dcee18abe50ec82dab6cbc4751b3a82046da54c52e3b2d035b3c519732c0b3dd7a2fa9df24efd1a38d953d8d4813c50985f215f1957ee5e4f26b0fe0da395 + languageName: node + linkType: hard + "type-fest@npm:^0.21.3": version: 0.21.3 resolution: "type-fest@npm:0.21.3" @@ -21783,6 +21995,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.6.0": + version: 0.6.0 + resolution: "type-fest@npm:0.6.0" + checksum: b2188e6e4b21557f6e92960ec496d28a51d68658018cba8b597bd3ef757721d1db309f120ae987abeeda874511d14b776157ff809f23c6d1ce8f83b9b2b7d60f + languageName: node + linkType: hard + "type-fest@npm:^0.7.1": version: 0.7.1 resolution: "type-fest@npm:0.7.1" @@ -21790,6 +22009,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.8.1": + version: 0.8.1 + resolution: "type-fest@npm:0.8.1" + checksum: d61c4b2eba24009033ae4500d7d818a94fd6d1b481a8111612ee141400d5f1db46f199c014766b9fa9b31a6a7374d96fc748c6d688a78a3ce5a33123839becb7 + languageName: node + linkType: hard + "type-fest@npm:^1.0.1, type-fest@npm:^1.2.1, type-fest@npm:^1.2.2": version: 1.4.0 resolution: "type-fest@npm:1.4.0" @@ -22802,7 +23028,7 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:^20.2.9": +"yargs-parser@npm:^20.2.3, yargs-parser@npm:^20.2.9": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" checksum: 8bb69015f2b0ff9e17b2c8e6bfe224ab463dd00ca211eece72a4cd8a906224d2703fb8a326d36fdd0e68701e201b2a60ed7cf81ce0fd9b3799f9fe7745977ae3