Skip to content

Commit 5615654

Browse files
authored
feat: useViewModelInstance use name and artboardName when getting vmi from a file (#129)
## Summary Fixes #124 - `useViewModelInstance` hook now correctly handles the `name` parameter: - `name` consistently means **ViewModel instance name** (not ViewModel class name) for both `RiveFile` and `ViewModel` sources - New `artboardName` param for `RiveFile` source to specify which artboard's ViewModel to use - TypeScript overloads enforce param availability by source type (e.g., `artboardName` only available for `RiveFile`) ## Test plan - [x] Unit tests pass (`yarn test`) - [x] TypeScript compiles (`yarn typecheck`) - [x] Lint passes (`yarn lint`) - [ ] Harness tests on device
1 parent 6bcaac2 commit 5615654

File tree

3 files changed

+470
-39
lines changed

3 files changed

+470
-39
lines changed

example/__tests__/hooks.harness.tsx

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,44 @@ import {
66
waitFor,
77
cleanup,
88
} from 'react-native-harness';
9-
import { useEffect } from 'react';
9+
import { useEffect, useMemo } from 'react';
1010
import { Text, View } from 'react-native';
11-
import { RiveFileFactory, useRiveNumber } from '@rive-app/react-native';
11+
import {
12+
RiveFileFactory,
13+
useRiveNumber,
14+
useViewModelInstance,
15+
type RiveFile,
16+
} from '@rive-app/react-native';
1217
import type { ViewModelInstance } from '@rive-app/react-native';
1318

1419
const QUICK_START = require('../assets/rive/quick_start.riv');
20+
const DATABINDING = require('../assets/rive/databinding.riv');
1521

16-
type HookContext = {
22+
type UseRiveNumberContext = {
1723
value: number | undefined;
1824
error: Error | null;
1925
setValue: ((v: number) => void) | null;
2026
};
2127

22-
function createHookContext(): HookContext {
28+
function createUseRiveNumberContext(): UseRiveNumberContext {
2329
return { value: undefined, error: null, setValue: null };
2430
}
2531

32+
type UseViewModelInstanceContext = {
33+
instance: ViewModelInstance | null;
34+
age: number | undefined;
35+
};
36+
37+
function createUseViewModelInstanceContext(): UseViewModelInstanceContext {
38+
return { instance: null, age: undefined };
39+
}
40+
2641
function UseRiveNumberTestComponent({
2742
instance,
2843
context,
2944
}: {
3045
instance: ViewModelInstance;
31-
context: HookContext;
46+
context: UseRiveNumberContext;
3247
}) {
3348
const { value, setValue, error } = useRiveNumber('health', instance);
3449

@@ -45,6 +60,34 @@ function UseRiveNumberTestComponent({
4560
);
4661
}
4762

63+
function UseViewModelInstanceTestComponent({
64+
file,
65+
context,
66+
}: {
67+
file: RiveFile;
68+
context: UseViewModelInstanceContext;
69+
}) {
70+
const instance = useViewModelInstance(file);
71+
72+
const age = useMemo(() => {
73+
if (!instance) return undefined;
74+
return instance.numberProperty('age')?.value;
75+
}, [instance]);
76+
77+
useEffect(() => {
78+
context.instance = instance;
79+
context.age = age;
80+
}, [context, instance, age]);
81+
82+
return (
83+
<View>
84+
<Text>
85+
instance={String(!!instance)} age={String(age)}
86+
</Text>
87+
</View>
88+
);
89+
}
90+
4891
function expectDefined<T>(value: T): asserts value is NonNullable<T> {
4992
expect(value).toBeDefined();
5093
}
@@ -57,7 +100,7 @@ describe('useRiveNumber Hook', () => {
57100
const instance = vm.createDefaultInstance();
58101
expectDefined(instance);
59102

60-
const context = createHookContext();
103+
const context = createUseRiveNumberContext();
61104
await render(
62105
<UseRiveNumberTestComponent instance={instance} context={context} />
63106
);
@@ -80,7 +123,7 @@ describe('useRiveNumber Hook', () => {
80123
const instance = vm.createDefaultInstance();
81124
expectDefined(instance);
82125

83-
const context = createHookContext();
126+
const context = createUseRiveNumberContext();
84127
await render(
85128
<UseRiveNumberTestComponent instance={instance} context={context} />
86129
);
@@ -101,3 +144,25 @@ describe('useRiveNumber Hook', () => {
101144
cleanup();
102145
});
103146
});
147+
148+
describe('useViewModelInstance hook', () => {
149+
it('gets default ViewModel instance from RiveFile', async () => {
150+
const file = await RiveFileFactory.fromSource(DATABINDING, undefined);
151+
const context = createUseViewModelInstanceContext();
152+
153+
await render(
154+
<UseViewModelInstanceTestComponent file={file} context={context} />
155+
);
156+
157+
await waitFor(
158+
() => {
159+
expect(context.instance).not.toBeNull();
160+
},
161+
{ timeout: 5000 }
162+
);
163+
164+
expect(context.age).toBe(30);
165+
166+
cleanup();
167+
});
168+
});
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import { renderHook } from '@testing-library/react-native';
2+
import { useViewModelInstance } from '../useViewModelInstance';
3+
import type { RiveFile } from '../../specs/RiveFile.nitro';
4+
import type { ViewModel, ViewModelInstance } from '../../specs/ViewModel.nitro';
5+
import type { ArtboardBy } from '../../specs/ArtboardBy';
6+
7+
function createMockViewModelInstance(): ViewModelInstance {
8+
return {
9+
instanceName: 'TestInstance',
10+
dispose: jest.fn(),
11+
numberProperty: jest.fn(),
12+
stringProperty: jest.fn(),
13+
booleanProperty: jest.fn(),
14+
colorProperty: jest.fn(),
15+
enumProperty: jest.fn(),
16+
triggerProperty: jest.fn(),
17+
imageProperty: jest.fn(),
18+
listProperty: jest.fn(),
19+
artboardProperty: jest.fn(),
20+
viewModel: jest.fn(),
21+
replaceViewModel: jest.fn(),
22+
} as any;
23+
}
24+
25+
function createMockViewModel(options?: {
26+
defaultInstance?: ViewModelInstance;
27+
namedInstances?: Record<string, ViewModelInstance>;
28+
}): ViewModel {
29+
return {
30+
propertyCount: 0,
31+
instanceCount: 1,
32+
modelName: 'TestViewModel',
33+
dispose: jest.fn(),
34+
createInstanceByIndex: jest.fn(),
35+
createInstanceByName: jest.fn(
36+
(name: string) => options?.namedInstances?.[name]
37+
),
38+
createDefaultInstance: jest.fn(() => options?.defaultInstance),
39+
createInstance: jest.fn(),
40+
} as any;
41+
}
42+
43+
function createMockRiveFile(options?: {
44+
defaultViewModel?: ViewModel;
45+
artboardViewModels?: Record<string, ViewModel>;
46+
}): RiveFile {
47+
return {
48+
dispose: jest.fn(),
49+
updateReferencedAssets: jest.fn(),
50+
viewModelCount: 0,
51+
artboardCount: 0,
52+
artboardNames: [],
53+
viewModelByIndex: jest.fn(),
54+
viewModelByName: jest.fn(),
55+
defaultArtboardViewModel: jest.fn((artboardBy?: ArtboardBy) => {
56+
if (artboardBy?.name && options?.artboardViewModels) {
57+
return options.artboardViewModels[artboardBy.name];
58+
}
59+
return options?.defaultViewModel;
60+
}),
61+
getBindableArtboard: jest.fn(),
62+
} as any;
63+
}
64+
65+
describe('useViewModelInstance - RiveFile with name parameter', () => {
66+
it('should use createInstanceByName when name is provided with RiveFile', () => {
67+
const personInstance = createMockViewModelInstance();
68+
const defaultViewModel = createMockViewModel({
69+
namedInstances: { PersonInstance: personInstance },
70+
});
71+
72+
const mockRiveFile = createMockRiveFile({ defaultViewModel });
73+
74+
const { result } = renderHook(() =>
75+
useViewModelInstance(mockRiveFile, { name: 'PersonInstance' })
76+
);
77+
78+
expect(mockRiveFile.defaultArtboardViewModel).toHaveBeenCalledWith(
79+
undefined
80+
);
81+
expect(defaultViewModel.createInstanceByName).toHaveBeenCalledWith(
82+
'PersonInstance'
83+
);
84+
expect(defaultViewModel.createDefaultInstance).not.toHaveBeenCalled();
85+
expect(result.current).toBe(personInstance);
86+
});
87+
88+
it('should use defaultArtboardViewModel and createDefaultInstance when no name provided', () => {
89+
const defaultInstance = createMockViewModelInstance();
90+
const defaultViewModel = createMockViewModel({ defaultInstance });
91+
92+
const mockRiveFile = createMockRiveFile({ defaultViewModel });
93+
94+
const { result } = renderHook(() => useViewModelInstance(mockRiveFile));
95+
96+
expect(mockRiveFile.defaultArtboardViewModel).toHaveBeenCalledWith(
97+
undefined
98+
);
99+
expect(defaultViewModel.createDefaultInstance).toHaveBeenCalled();
100+
expect(defaultViewModel.createInstanceByName).not.toHaveBeenCalled();
101+
expect(result.current).toBe(defaultInstance);
102+
});
103+
104+
it('should return null when instance name not found and required is false', () => {
105+
const defaultViewModel = createMockViewModel({
106+
namedInstances: {},
107+
});
108+
109+
const mockRiveFile = createMockRiveFile({ defaultViewModel });
110+
111+
const { result } = renderHook(() =>
112+
useViewModelInstance(mockRiveFile, { name: 'NonExistent' })
113+
);
114+
115+
expect(result.current).toBeNull();
116+
});
117+
118+
it('should throw when instance name not found and required is true', () => {
119+
const defaultViewModel = createMockViewModel({
120+
namedInstances: {},
121+
});
122+
123+
const mockRiveFile = createMockRiveFile({ defaultViewModel });
124+
125+
expect(() =>
126+
renderHook(() =>
127+
useViewModelInstance(mockRiveFile, {
128+
name: 'NonExistent',
129+
required: true,
130+
})
131+
)
132+
).toThrow("ViewModel instance 'NonExistent' not found");
133+
});
134+
135+
it('should return null when artboardName not found and required is false', () => {
136+
const mockRiveFile = createMockRiveFile({
137+
artboardViewModels: {},
138+
});
139+
140+
const { result } = renderHook(() =>
141+
useViewModelInstance(mockRiveFile, { artboardName: 'MissingArtboard' })
142+
);
143+
144+
expect(result.current).toBeNull();
145+
});
146+
147+
it('should throw when artboardName not found and required is true', () => {
148+
const mockRiveFile = createMockRiveFile({
149+
artboardViewModels: {},
150+
});
151+
152+
expect(() =>
153+
renderHook(() =>
154+
useViewModelInstance(mockRiveFile, {
155+
artboardName: 'MissingArtboard',
156+
required: true,
157+
})
158+
)
159+
).toThrow("Artboard 'MissingArtboard' not found or has no ViewModel");
160+
});
161+
162+
it('should call onInit when instance is created with name parameter', () => {
163+
const personInstance = createMockViewModelInstance();
164+
const defaultViewModel = createMockViewModel({
165+
namedInstances: { PersonInstance: personInstance },
166+
});
167+
const onInit = jest.fn();
168+
169+
const mockRiveFile = createMockRiveFile({ defaultViewModel });
170+
171+
renderHook(() =>
172+
useViewModelInstance(mockRiveFile, { name: 'PersonInstance', onInit })
173+
);
174+
175+
expect(onInit).toHaveBeenCalledWith(personInstance);
176+
});
177+
});
178+
179+
describe('useViewModelInstance - RiveFile with artboardName parameter', () => {
180+
it('should use artboardName to get ViewModel from specific artboard', () => {
181+
const mainInstance = createMockViewModelInstance();
182+
const mainArtboardViewModel = createMockViewModel({
183+
defaultInstance: mainInstance,
184+
});
185+
186+
const mockRiveFile = createMockRiveFile({
187+
artboardViewModels: { MainArtboard: mainArtboardViewModel },
188+
});
189+
190+
const { result } = renderHook(() =>
191+
useViewModelInstance(mockRiveFile, { artboardName: 'MainArtboard' })
192+
);
193+
194+
expect(mockRiveFile.defaultArtboardViewModel).toHaveBeenCalledWith({
195+
type: 'name',
196+
name: 'MainArtboard',
197+
});
198+
expect(mainArtboardViewModel.createDefaultInstance).toHaveBeenCalled();
199+
expect(result.current).toBe(mainInstance);
200+
});
201+
202+
it('should combine artboardName and name to get specific instance from specific artboard', () => {
203+
const specificInstance = createMockViewModelInstance();
204+
const mainArtboardViewModel = createMockViewModel({
205+
namedInstances: { SpecificInstance: specificInstance },
206+
});
207+
208+
const mockRiveFile = createMockRiveFile({
209+
artboardViewModels: { MainArtboard: mainArtboardViewModel },
210+
});
211+
212+
const { result } = renderHook(() =>
213+
useViewModelInstance(mockRiveFile, {
214+
artboardName: 'MainArtboard',
215+
name: 'SpecificInstance',
216+
})
217+
);
218+
219+
expect(mockRiveFile.defaultArtboardViewModel).toHaveBeenCalledWith({
220+
type: 'name',
221+
name: 'MainArtboard',
222+
});
223+
expect(mainArtboardViewModel.createInstanceByName).toHaveBeenCalledWith(
224+
'SpecificInstance'
225+
);
226+
expect(result.current).toBe(specificInstance);
227+
});
228+
});
229+
230+
describe('useViewModelInstance - ViewModel source', () => {
231+
it('should use createInstanceByName when name is provided with ViewModel', () => {
232+
const namedInstance = createMockViewModelInstance();
233+
const mockViewModel = createMockViewModel({
234+
namedInstances: { Gordon: namedInstance },
235+
});
236+
237+
const { result } = renderHook(() =>
238+
useViewModelInstance(mockViewModel, { name: 'Gordon' })
239+
);
240+
241+
expect(mockViewModel.createInstanceByName).toHaveBeenCalledWith('Gordon');
242+
expect(mockViewModel.createDefaultInstance).not.toHaveBeenCalled();
243+
expect(result.current).toBe(namedInstance);
244+
});
245+
246+
it('should use createInstance when useNew is true', () => {
247+
const newInstance = createMockViewModelInstance();
248+
const mockViewModel = createMockViewModel();
249+
(mockViewModel.createInstance as jest.Mock).mockReturnValue(newInstance);
250+
251+
const { result } = renderHook(() =>
252+
useViewModelInstance(mockViewModel, { useNew: true })
253+
);
254+
255+
expect(mockViewModel.createInstance).toHaveBeenCalled();
256+
expect(mockViewModel.createDefaultInstance).not.toHaveBeenCalled();
257+
expect(result.current).toBe(newInstance);
258+
});
259+
260+
it('should use createDefaultInstance when no params provided', () => {
261+
const defaultInstance = createMockViewModelInstance();
262+
const mockViewModel = createMockViewModel({ defaultInstance });
263+
264+
const { result } = renderHook(() => useViewModelInstance(mockViewModel));
265+
266+
expect(mockViewModel.createDefaultInstance).toHaveBeenCalled();
267+
expect(result.current).toBe(defaultInstance);
268+
});
269+
});

0 commit comments

Comments
 (0)