From 74d395dd4a0a95f62872a51515ef92adf528bfa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 18 Mar 2026 09:08:36 +0100 Subject: [PATCH 1/3] test(harness): assert first listener emission fires without prop update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an explicit test for the contract that `useRiveNumber` delivers its initial value purely from the first listener emission on subscribe — no prop change is required. Uses a tight 1 s timeout to make it fail fast if the hook ever accidentally regresses to relying on a prop update cycle. --- example/__tests__/hooks.harness.tsx | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/example/__tests__/hooks.harness.tsx b/example/__tests__/hooks.harness.tsx index b4bb234b..380cd785 100644 --- a/example/__tests__/hooks.harness.tsx +++ b/example/__tests__/hooks.harness.tsx @@ -146,6 +146,37 @@ describe('useRiveNumber Hook', () => { cleanup(); }); + + it('delivers initial value on first listener emission without any prop update', async () => { + const file = await RiveFileFactory.fromSource(QUICK_START, undefined); + const vm = file.defaultArtboardViewModel(); + expectDefined(vm); + const instance = vm.createDefaultInstance(); + expectDefined(instance); + + const context = createUseRiveNumberContext(); + + // Value must be undefined before mounting — no sneaky sync reads + expect(context.value).toBeUndefined(); + + await render( + + ); + + // The listener fires its first emission on subscribe — no prop change required. + // Tight timeout: if this relied on a prop update cycle it would be flaky/fail. + await waitFor( + () => { + expect(typeof context.value).toBe('number'); + }, + { timeout: 1000 } + ); + + // Confirm no error occurred + expect(context.error).toBeNull(); + + cleanup(); + }); }); describe('useViewModelInstance hook', () => { From 8093b4d24d63331a07a935fdbf440111347950c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 18 Mar 2026 10:31:12 +0100 Subject: [PATCH 2/3] feat(example): add Data Binding (expapi) exerciser using async VM API Mirror of the existing Data Binding exerciser but with WithViewModelSetup replaced to use `defaultArtboardViewModelAsync()` and `createDefaultInstanceAsync()` instead of the deprecated sync counterparts. Useful for verifying the async path produces identical visual results. --- .../RiveDataBindingExampleExpApi.tsx | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 example/src/exercisers/RiveDataBindingExampleExpApi.tsx diff --git a/example/src/exercisers/RiveDataBindingExampleExpApi.tsx b/example/src/exercisers/RiveDataBindingExampleExpApi.tsx new file mode 100644 index 00000000..8f4c2704 --- /dev/null +++ b/example/src/exercisers/RiveDataBindingExampleExpApi.tsx @@ -0,0 +1,163 @@ +import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'; +import { useEffect, useState } from 'react'; +import { + Fit, + RiveView, + useRiveNumber, + type ViewModelInstance, + type RiveFile, + useRiveString, + useRiveColor, + useRiveTrigger, + useRiveFile, +} from '@rive-app/react-native'; +import { type Metadata } from '../shared/metadata'; + +export default function WithRiveFile() { + const { riveFile, isLoading, error } = useRiveFile( + require('../../assets/rive/rewards.riv') + ); + + return ( + + + {isLoading ? ( + + ) : riveFile ? ( + + ) : ( + {error || 'Unexpected error'} + )} + + + ); +} + +function WithViewModelSetup({ file }: { file: RiveFile }) { + const [instance, setInstance] = useState( + undefined + ); + const [setupError, setSetupError] = useState(undefined); + + useEffect(() => { + let cancelled = false; + + async function setup() { + const viewModel = await file.defaultArtboardViewModelAsync(); + if (cancelled) return; + + if (!viewModel) { + setSetupError('No view model found'); + return; + } + + const vmi = await viewModel.createDefaultInstanceAsync(); + if (cancelled) return; + + if (!vmi) { + setSetupError('Failed to create view model instance'); + return; + } + + setInstance(vmi); + } + + setup().catch((e: unknown) => { + if (!cancelled) { + setSetupError(String(e)); + } + }); + + return () => { + cancelled = true; + }; + }, [file]); + + if (setupError) { + return {setupError}; + } + + if (!instance) { + return ; + } + + return ; +} + +function DataBindingExample({ + instance, + file, +}: { + instance: ViewModelInstance; + file: RiveFile; +}) { + const { error: coinValueError } = useRiveNumber('Coin/Item_Value', instance); + + if (coinValueError) { + console.error('coinValueError', coinValueError); + } + + const { setValue: setButtonText } = useRiveString('Button/State_1', instance); + + const { setValue: setBarColor, error: barColorError } = useRiveColor( + 'Energy_Bar/Bar_Color', + instance + ); + + if (barColorError) { + console.error('barColorError', barColorError); + } + + const { error: triggerError } = useRiveTrigger('Button/Pressed', instance, { + onTrigger: () => { + console.log('Button pressed'); + }, + }); + + if (triggerError) { + console.error('triggerError', triggerError); + } + + useEffect(() => { + setButtonText("Let's go!"); + setBarColor('#0000FF'); + }, [setBarColor, setButtonText]); + + return ( + + ); +} + +WithRiveFile.metadata = { + name: 'Data Binding (expapi)', + description: + 'Same as Data Binding but uses the async API (defaultArtboardViewModelAsync / createDefaultInstanceAsync)', +} satisfies Metadata; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + riveContainer: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + rive: { + flex: 1, + width: '100%', + height: '100%', + }, + errorText: { + color: 'red', + textAlign: 'center', + padding: 20, + }, +}); From d4d6195946a89e077a490cdf523d863c80f03a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 26 Mar 2026 09:41:46 +0100 Subject: [PATCH 3/3] migrate examples to async API; add @typescript-eslint/no-deprecated lint rule --- eslint.config.mjs | 20 +++++++++-- .../src/demos/DataBindingArtboardsExample.tsx | 22 +++--------- example/src/demos/QuickStart.tsx | 2 +- .../src/exercisers/FontFallbackExample.tsx | 20 ++++------- example/src/exercisers/ManyViewModels.tsx | 33 +++++++++++------ example/src/exercisers/MenuListExample.tsx | 35 +++++++++++++------ .../src/exercisers/NestedViewModelExample.tsx | 26 +++++--------- .../src/exercisers/RiveDataBindingExample.tsx | 19 +++------- example/src/exercisers/RiveEventsExample.tsx | 1 + .../RiveStateMachineInputsExample.tsx | 1 + example/src/exercisers/RiveTextRunExample.tsx | 1 + .../__tests__/useViewModelInstance.test.ts | 1 + src/hooks/useRiveList.ts | 2 ++ src/hooks/useViewModelInstance.ts | 2 ++ 14 files changed, 97 insertions(+), 88 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index b891a1dd..447ee604 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,8 @@ -import { fixupConfigRules } from '@eslint/compat'; +import { fixupConfigRules, fixupPluginRules } from '@eslint/compat'; import { FlatCompat } from '@eslint/eslintrc'; import js from '@eslint/js'; +import tsPlugin from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; import prettier from 'eslint-plugin-prettier'; import { defineConfig } from 'eslint/config'; import path from 'node:path'; @@ -15,8 +17,8 @@ const compat = new FlatCompat({ }); export default defineConfig([ + ...fixupConfigRules(compat.extends('@react-native', 'prettier')), { - extends: fixupConfigRules(compat.extends('@react-native', 'prettier')), plugins: { prettier }, rules: { 'react/react-in-jsx-scope': 'off', @@ -33,6 +35,20 @@ export default defineConfig([ ], }, }, + { + files: ['src/**/*.{ts,tsx}', 'example/src/**/*.{ts,tsx}'], + plugins: { '@typescript-eslint': fixupPluginRules(tsPlugin) }, + languageOptions: { + parser: tsParser, + parserOptions: { + projectService: true, + tsconfigRootDir: __dirname, + }, + }, + rules: { + '@typescript-eslint/no-deprecated': 'error', + }, + }, { ignores: [ 'node_modules/', diff --git a/example/src/demos/DataBindingArtboardsExample.tsx b/example/src/demos/DataBindingArtboardsExample.tsx index 9af9b70b..3de225be 100644 --- a/example/src/demos/DataBindingArtboardsExample.tsx +++ b/example/src/demos/DataBindingArtboardsExample.tsx @@ -5,11 +5,12 @@ import { ActivityIndicator, Pressable, } from 'react-native'; -import { useState, useMemo, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Fit, RiveView, useRiveFile, + useViewModelInstance, type RiveFile, type BindableArtboard, } from '@rive-app/react-native'; @@ -77,16 +78,7 @@ function ArtboardSwapper({ mainFile: RiveFile; assetsFile: RiveFile; }) { - // Get the view model from the "Main" artboard and create an instance - // IMPORTANT: Must memoize to prevent creating new instance on every render - const viewModel = useMemo( - () => mainFile.defaultArtboardViewModel(), - [mainFile] - ); - const instance = useMemo( - () => viewModel?.createDefaultInstance(), - [viewModel] - ); + const instance = useViewModelInstance(mainFile); const [currentArtboard, setCurrentArtboard] = useState('Dragon'); const initializedRef = useRef(false); @@ -134,14 +126,10 @@ function ArtboardSwapper({ } }; - if (!instance || !viewModel) { + if (!instance) { return ( - - {!viewModel - ? 'No view model found in main file' - : 'Failed to create instance'} - + ); } diff --git a/example/src/demos/QuickStart.tsx b/example/src/demos/QuickStart.tsx index 95c2493e..4e167183 100644 --- a/example/src/demos/QuickStart.tsx +++ b/example/src/demos/QuickStart.tsx @@ -24,7 +24,7 @@ export default function QuickStart() { ); const { riveViewRef, setHybridRef } = useRive(); const viewModelInstance = useViewModelInstance(riveFile, { - onInit: (vmi) => (vmi.numberProperty('health')!.value = 9), + onInit: (vmi) => vmi.numberProperty('health')!.set(9), }); const { setValue: setHealth } = useRiveNumber('health', viewModelInstance); diff --git a/example/src/exercisers/FontFallbackExample.tsx b/example/src/exercisers/FontFallbackExample.tsx index 84ecf81d..206f9c49 100644 --- a/example/src/exercisers/FontFallbackExample.tsx +++ b/example/src/exercisers/FontFallbackExample.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { View, Text, @@ -15,6 +15,7 @@ import { useRive, useRiveFile, useRiveString, + useViewModelInstance, type FontSource, type FallbackFont, } from '@rive-app/react-native'; @@ -257,14 +258,7 @@ function MountedView({ text }: { text: string }) { // https://rive.app/marketplace/26480-49641-simple-test-text-property/ require('../../assets/rive/font_fallback.riv') ); - const viewModel = useMemo( - () => riveFile?.defaultArtboardViewModel(), - [riveFile] - ); - const instance = useMemo( - () => viewModel?.createDefaultInstance(), - [viewModel] - ); + const instance = useViewModelInstance(riveFile ?? null); const { setValue: setRiveText, error: textError } = useRiveString( TEXT_PROPERTY, @@ -280,7 +274,7 @@ function MountedView({ text }: { text: string }) { riveViewRef?.playIfNeeded(); }, [text, setRiveText, riveViewRef]); - if (isLoading) { + if (isLoading || !instance) { return ( @@ -288,12 +282,10 @@ function MountedView({ text }: { text: string }) { ); } - if (error || !riveFile || !instance) { + if (error || !riveFile) { return ( - - {error || 'Failed to set up view model'} - + {error || 'Failed to load file'} ); } diff --git a/example/src/exercisers/ManyViewModels.tsx b/example/src/exercisers/ManyViewModels.tsx index 0fd994f5..8407f823 100644 --- a/example/src/exercisers/ManyViewModels.tsx +++ b/example/src/exercisers/ManyViewModels.tsx @@ -1,5 +1,5 @@ import { StyleSheet, View, Text, TouchableOpacity, Button } from 'react-native'; -import { useState, useMemo, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect } from 'react'; import type { Metadata } from '../shared/metadata'; import { DataBindMode, @@ -84,17 +84,28 @@ export default function ManyViewModels() { const riveViewRef = useRef(undefined); const isListening = useRef(false); - // Create a ViewModelInstance for "green" to demonstrate instance binding - const greenInstance = useMemo(() => { - if (!riveFile) return undefined; - try { - const viewModel = riveFile.defaultArtboardViewModel(); - if (!viewModel) return undefined; - return viewModel.createInstanceByName('green'); - } catch (e) { - console.error('Failed to create green instance:', e); - return undefined; + const [greenInstance, setGreenInstance] = useState< + ViewModelInstance | undefined + >(undefined); + + useEffect(() => { + if (!riveFile) return; + let cancelled = false; + + async function setup() { + const viewModel = await riveFile!.defaultArtboardViewModelAsync(); + if (cancelled || !viewModel) return; + const instance = await viewModel.createInstanceByNameAsync('green'); + if (!cancelled) setGreenInstance(instance ?? undefined); } + + setup().catch((e) => { + if (!cancelled) console.error('Failed to create green instance:', e); + }); + + return () => { + cancelled = true; + }; }, [riveFile]); const handleLoadImage = async () => { diff --git a/example/src/exercisers/MenuListExample.tsx b/example/src/exercisers/MenuListExample.tsx index c6cf5dfc..323bf473 100644 --- a/example/src/exercisers/MenuListExample.tsx +++ b/example/src/exercisers/MenuListExample.tsx @@ -7,10 +7,11 @@ import { TextInput, ScrollView, } from 'react-native'; -import { useRef, useMemo } from 'react'; +import { useRef, useState, useEffect } from 'react'; import { Fit, RiveView, + type ViewModel, type ViewModelInstance, type RiveFile, useRiveFile, @@ -79,15 +80,27 @@ function MenuListContent({ error, } = useRiveList('menu', instance); - const listItemViewModel = useMemo( - () => file.viewModelByName('listItem'), - [file] - ); + const [listItemViewModel, setListItemViewModel] = useState< + ViewModel | undefined + >(undefined); + + useEffect(() => { + let cancelled = false; + file + .viewModelByNameAsync('listItem') + .then((vm) => { + if (!cancelled) setListItemViewModel(vm ?? undefined); + }) + .catch((e) => console.error('Failed to load listItem view model:', e)); + return () => { + cancelled = true; + }; + }, [file]); - const addNewMenuItem = (label: string) => { + const addNewMenuItem = async (label: string) => { if (!listItemViewModel) return; - const newMenuItemVmi = listItemViewModel.createInstance(); + const newMenuItemVmi = await listItemViewModel.createBlankInstanceAsync(); if (!newMenuItemVmi) return; const labelProperty = newMenuItemVmi.stringProperty('label'); @@ -96,9 +109,9 @@ function MenuListContent({ if (!labelProperty || !hoverColorProperty || !fontIconProperty) return; - labelProperty.value = label; - hoverColorProperty.value = 0xff323232; - fontIconProperty.value = ''; + labelProperty.set(label); + hoverColorProperty.set(0xff323232); + fontIconProperty.set(''); lastAdded.current = newMenuItemVmi; addInstance(newMenuItemVmi); @@ -126,7 +139,7 @@ function MenuListContent({ const menuItemLabel = menuItem.stringProperty('label'); if (!menuItemLabel) return; - menuItemLabel.value = label; + menuItemLabel.set(label); }; return ( diff --git a/example/src/exercisers/NestedViewModelExample.tsx b/example/src/exercisers/NestedViewModelExample.tsx index c90ef875..efa997b4 100644 --- a/example/src/exercisers/NestedViewModelExample.tsx +++ b/example/src/exercisers/NestedViewModelExample.tsx @@ -6,12 +6,13 @@ import { Button, TextInput, } from 'react-native'; -import { useMemo, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { Fit, RiveView, useRiveFile, useRiveString, + useViewModelInstance, type ViewModelInstance, type RiveFile, type RiveViewRef, @@ -37,20 +38,10 @@ export default function NestedViewModelExample() { } function WithViewModelSetup({ file }: { file: RiveFile }) { - const viewModel = useMemo(() => file.defaultArtboardViewModel(), [file]); - const instance = useMemo( - () => viewModel?.createDefaultInstance(), - [viewModel] - ); + const instance = useViewModelInstance(file); - if (!instance || !viewModel) { - return ( - - {!viewModel - ? 'No view model found' - : 'Failed to create view model instance'} - - ); + if (!instance) { + return ; } return ; @@ -88,11 +79,10 @@ function ReplaceViewModelTest({ const addLog = (msg: string) => setLog((prev) => [...prev, msg]); - const handleReplace = () => { - // Get vm2's instance - const vm2Instance = instance.viewModel('vm2'); + const handleReplace = async () => { + const vm2Instance = await instance.viewModelAsync('vm2'); if (!vm2Instance) { - addLog('❌ viewModel("vm2") returned undefined'); + addLog('❌ viewModelAsync("vm2") returned undefined'); return; } addLog(`✅ Got vm2 instance: ${vm2Instance.instanceName}`); diff --git a/example/src/exercisers/RiveDataBindingExample.tsx b/example/src/exercisers/RiveDataBindingExample.tsx index d700eecb..42b0b1a2 100644 --- a/example/src/exercisers/RiveDataBindingExample.tsx +++ b/example/src/exercisers/RiveDataBindingExample.tsx @@ -1,9 +1,10 @@ import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'; -import { useEffect, useMemo } from 'react'; +import { useEffect } from 'react'; import { Fit, RiveView, useRiveNumber, + useViewModelInstance, type ViewModelInstance, type RiveFile, useRiveString, @@ -34,20 +35,10 @@ export default function WithRiveFile() { } function WithViewModelSetup({ file }: { file: RiveFile }) { - const viewModel = useMemo(() => file.defaultArtboardViewModel(), [file]); - const instance = useMemo( - () => viewModel?.createDefaultInstance(), - [viewModel] - ); + const instance = useViewModelInstance(file); - if (!instance || !viewModel) { - return ( - - {!viewModel - ? 'No view model found' - : 'Failed to create view model instance'} - - ); + if (!instance) { + return ; } return ; diff --git a/example/src/exercisers/RiveEventsExample.tsx b/example/src/exercisers/RiveEventsExample.tsx index 413ef290..e6eec0d8 100644 --- a/example/src/exercisers/RiveEventsExample.tsx +++ b/example/src/exercisers/RiveEventsExample.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-deprecated */ import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'; import { useState, useEffect } from 'react'; import { diff --git a/example/src/exercisers/RiveStateMachineInputsExample.tsx b/example/src/exercisers/RiveStateMachineInputsExample.tsx index 4a0ae8ee..6ec37820 100644 --- a/example/src/exercisers/RiveStateMachineInputsExample.tsx +++ b/example/src/exercisers/RiveStateMachineInputsExample.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-deprecated */ import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'; import { useEffect } from 'react'; import { Fit, RiveView, useRive, useRiveFile } from '@rive-app/react-native'; diff --git a/example/src/exercisers/RiveTextRunExample.tsx b/example/src/exercisers/RiveTextRunExample.tsx index d0c98f8a..19f7fa44 100644 --- a/example/src/exercisers/RiveTextRunExample.tsx +++ b/example/src/exercisers/RiveTextRunExample.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-deprecated */ import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'; import { useEffect } from 'react'; import { Fit, RiveView, useRive, useRiveFile } from '@rive-app/react-native'; diff --git a/src/hooks/__tests__/useViewModelInstance.test.ts b/src/hooks/__tests__/useViewModelInstance.test.ts index 09a70cd6..1e6382b2 100644 --- a/src/hooks/__tests__/useViewModelInstance.test.ts +++ b/src/hooks/__tests__/useViewModelInstance.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-deprecated */ import { renderHook } from '@testing-library/react-native'; import { useViewModelInstance } from '../useViewModelInstance'; import type { RiveFile } from '../../specs/RiveFile.nitro'; diff --git a/src/hooks/useRiveList.ts b/src/hooks/useRiveList.ts index 71d09785..29d90225 100644 --- a/src/hooks/useRiveList.ts +++ b/src/hooks/useRiveList.ts @@ -1,3 +1,5 @@ +// TODO: migrate length/getInstanceAt to async equivalents +/* eslint-disable @typescript-eslint/no-deprecated */ import { useCallback, useEffect, useState, useMemo } from 'react'; import type { ViewModelInstance } from '../specs/ViewModel.nitro'; import type { UseRiveListResult } from '../types'; diff --git a/src/hooks/useViewModelInstance.ts b/src/hooks/useViewModelInstance.ts index 2fcaeff3..8534e473 100644 --- a/src/hooks/useViewModelInstance.ts +++ b/src/hooks/useViewModelInstance.ts @@ -1,3 +1,5 @@ +// TODO: migrate createInstance/createInstanceByName/etc to async equivalents +/* eslint-disable @typescript-eslint/no-deprecated */ import { useMemo, useEffect, useRef } from 'react'; import type { ViewModel, ViewModelInstance } from '../specs/ViewModel.nitro'; import type { RiveFile } from '../specs/RiveFile.nitro';