Skip to content

Commit f71f8b4

Browse files
committed
fix!: hooks start undefined, useViewModelInstance returns {instance, error}
useRiveNumber/String/Boolean/Color/Enum hooks start as undefined — the real value arrives via listener. useViewModelInstance returns a discriminated union: undefined (loading), null (resolved empty/error), or ViewModelInstance (success).
1 parent e9779d2 commit f71f8b4

12 files changed

+176
-65
lines changed

example/__tests__/hooks.harness.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,15 @@ type UseRiveNumberContext = {
2626
value: number | undefined;
2727
error: Error | null;
2828
setValue: ((v: number) => void) | null;
29+
renderValues: (number | undefined)[];
2930
};
3031

3132
function createUseRiveNumberContext(): UseRiveNumberContext {
32-
return { value: undefined, error: null, setValue: null };
33+
return { value: undefined, error: null, setValue: null, renderValues: [] };
3334
}
3435

3536
type UseViewModelInstanceContext = {
36-
instance: ViewModelInstance | null;
37+
instance: ViewModelInstance | null | undefined;
3738
age: number | undefined;
3839
};
3940

@@ -50,6 +51,8 @@ function UseRiveNumberTestComponent({
5051
}) {
5152
const { value, setValue, error } = useRiveNumber('health', instance);
5253

54+
context.renderValues.push(value);
55+
5356
useEffect(() => {
5457
context.value = value;
5558
context.error = error;
@@ -70,7 +73,7 @@ function UseViewModelInstanceTestComponent({
7073
file: RiveFile;
7174
context: UseViewModelInstanceContext;
7275
}) {
73-
const instance = useViewModelInstance(file);
76+
const { instance } = useViewModelInstance(file);
7477

7578
const age = useMemo(() => {
7679
if (!instance) return undefined;
@@ -96,6 +99,34 @@ function expectDefined<T>(value: T): asserts value is NonNullable<T> {
9699
}
97100

98101
describe('useRiveNumber Hook', () => {
102+
it('starts undefined then receives value via listener', async () => {
103+
const file = await RiveFileFactory.fromSource(QUICK_START, undefined);
104+
const vm = file.defaultArtboardViewModel();
105+
expectDefined(vm);
106+
const instance = vm.createDefaultInstance();
107+
expectDefined(instance);
108+
109+
const context = createUseRiveNumberContext();
110+
111+
await render(
112+
<UseRiveNumberTestComponent instance={instance} context={context} />
113+
);
114+
115+
// First render must produce undefined — not a synchronous read from property.value
116+
expect(context.renderValues[0]).toBeUndefined();
117+
118+
// After listener fires, value should be a number
119+
await waitFor(
120+
() => {
121+
expect(context.error).toBeNull();
122+
expect(typeof context.value).toBe('number');
123+
},
124+
{ timeout: 5000 }
125+
);
126+
127+
cleanup();
128+
});
129+
99130
it('returns value from number property', async () => {
100131
const file = await RiveFileFactory.fromSource(QUICK_START, undefined);
101132
const vm = file.defaultArtboardViewModel();

example/src/demos/DataBindingArtboardsExample.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ function ArtboardSwapper({
7878
mainFile: RiveFile;
7979
assetsFile: RiveFile;
8080
}) {
81-
const instance = useViewModelInstance(mainFile);
81+
const { instance, error } = useViewModelInstance(mainFile);
8282
const [currentArtboard, setCurrentArtboard] = useState<string>('Dragon');
8383
const initializedRef = useRef(false);
8484

@@ -98,6 +98,11 @@ function ArtboardSwapper({
9898
}
9999
}, [instance, assetsFile]);
100100

101+
if (error) {
102+
console.error(error.message);
103+
return <Text style={{ color: 'red' }}>{error.message}</Text>;
104+
}
105+
101106
// Map display names to actual artboard names
102107
const artboardOptions = [
103108
{ label: 'Dragon', artboard: 'Character 1', fromAssets: true },

example/src/demos/QuickStart.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export default function QuickStart() {
2323
require('../../assets/rive/quick_start.riv')
2424
);
2525
const { riveViewRef, setHybridRef } = useRive();
26-
const viewModelInstance = useViewModelInstance(riveFile, {
26+
const { instance: viewModelInstance } = useViewModelInstance(riveFile, {
2727
onInit: (vmi) => vmi.numberProperty('health')!.set(9),
2828
});
2929

example/src/exercisers/FontFallbackExample.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ function MountedView({ text }: { text: string }) {
258258
// https://rive.app/marketplace/26480-49641-simple-test-text-property/
259259
require('../../assets/rive/font_fallback.riv')
260260
);
261-
const instance = useViewModelInstance(riveFile ?? null);
261+
const { instance } = useViewModelInstance(riveFile ?? null);
262262

263263
const { setValue: setRiveText, error: textError } = useRiveString(
264264
TEXT_PROPERTY,

example/src/exercisers/MenuListExample.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ export default function MenuListExample() {
3939
}
4040

4141
function MenuList({ file }: { file: RiveFile }) {
42-
const instance = useViewModelInstance(file, { required: true });
42+
const { instance, error } = useViewModelInstance(file);
43+
44+
if (error) {
45+
console.error(error.message);
46+
return <Text style={{ color: 'red' }}>{error.message}</Text>;
47+
}
4348

4449
if (!instance) {
4550
return <ActivityIndicator size="large" color="#007AFF" />;

example/src/exercisers/NestedViewModelExample.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@ export default function NestedViewModelExample() {
3838
}
3939

4040
function WithViewModelSetup({ file }: { file: RiveFile }) {
41-
const instance = useViewModelInstance(file);
41+
const { instance, error } = useViewModelInstance(file);
42+
43+
if (error) {
44+
console.error(error.message);
45+
return <Text style={{ color: 'red' }}>{error.message}</Text>;
46+
}
4247

4348
if (!instance) {
4449
return <ActivityIndicator size="large" color="#0000ff" />;

example/src/exercisers/RiveDataBindingExample.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ export default function WithRiveFile() {
3535
}
3636

3737
function WithViewModelSetup({ file }: { file: RiveFile }) {
38-
const instance = useViewModelInstance(file);
38+
const { instance, error } = useViewModelInstance(file);
39+
40+
if (error) {
41+
console.error(error.message);
42+
return <Text style={{ color: 'red' }}>{error.message}</Text>;
43+
}
3944

4045
if (!instance) {
4146
return <ActivityIndicator size="large" color="#0000ff" />;

src/hooks/__tests__/useRiveProperty.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ describe('useRiveProperty', () => {
1717
},
1818
addListener: jest.fn((callback: (value: string) => void) => {
1919
listener = callback;
20+
// Emit the current value immediately on subscribe, matching native behaviour:
21+
// iOS legacy emits synchronously; experimental backend emits via valueStream.
22+
callback(currentValue);
2023
return () => {
2124
listener = null;
2225
};
@@ -36,7 +39,9 @@ describe('useRiveProperty', () => {
3639
} as unknown as ViewModelInstance;
3740
};
3841

39-
it('should return initial value from property on first render', () => {
42+
it('should return initial value delivered via listener (not from a sync read)', () => {
43+
// Hooks always start undefined; the listener emits the current value immediately
44+
// on subscribe (synchronously for legacy, via stream for experimental).
4045
const mockProperty = createMockProperty('Tea');
4146
const mockInstance = createMockViewModelInstance({
4247
'favDrink/type': mockProperty,
@@ -48,6 +53,8 @@ describe('useRiveProperty', () => {
4853
})
4954
);
5055

56+
// The mock's addListener emits 'Tea' synchronously — React batches it with the
57+
// effect, so the value is available after renderHook (which wraps in act()).
5158
const [value] = result.current;
5259
expect(value).toBe('Tea');
5360
});

src/hooks/__tests__/useViewModelInstance.test.ts

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ describe('useViewModelInstance - RiveFile with instanceName parameter', () => {
8686
'PersonInstance'
8787
);
8888
expect(defaultViewModel.createDefaultInstance).not.toHaveBeenCalled();
89-
expect(result.current).toBe(personInstance);
89+
expect(result.current.instance).toBe(personInstance);
90+
expect(result.current.error).toBeNull();
9091
});
9192

9293
it('should use defaultArtboardViewModel and createDefaultInstance when no instanceName provided', () => {
@@ -102,10 +103,11 @@ describe('useViewModelInstance - RiveFile with instanceName parameter', () => {
102103
);
103104
expect(defaultViewModel.createDefaultInstance).toHaveBeenCalled();
104105
expect(defaultViewModel.createInstanceByName).not.toHaveBeenCalled();
105-
expect(result.current).toBe(defaultInstance);
106+
expect(result.current.instance).toBe(defaultInstance);
107+
expect(result.current.error).toBeNull();
106108
});
107109

108-
it('should return null when instance name not found and required is false', () => {
110+
it('should return error when instance name not found and required is false', () => {
109111
const defaultViewModel = createMockViewModel({
110112
namedInstances: {},
111113
});
@@ -116,7 +118,9 @@ describe('useViewModelInstance - RiveFile with instanceName parameter', () => {
116118
useViewModelInstance(mockRiveFile, { instanceName: 'NonExistent' })
117119
);
118120

119-
expect(result.current).toBeNull();
121+
expect(result.current.instance).toBeNull();
122+
expect(result.current.error).toBeInstanceOf(Error);
123+
expect(result.current.error?.message).toContain('NonExistent');
120124
});
121125

122126
it('should throw when instance name not found and required is true', () => {
@@ -136,7 +140,7 @@ describe('useViewModelInstance - RiveFile with instanceName parameter', () => {
136140
).toThrow("ViewModel instance 'NonExistent' not found");
137141
});
138142

139-
it('should return null when artboardName not found and required is false', () => {
143+
it('should return error when artboardName not found and required is false', () => {
140144
const mockRiveFile = createMockRiveFile({
141145
artboardViewModels: {},
142146
});
@@ -145,7 +149,9 @@ describe('useViewModelInstance - RiveFile with instanceName parameter', () => {
145149
useViewModelInstance(mockRiveFile, { artboardName: 'MissingArtboard' })
146150
);
147151

148-
expect(result.current).toBeNull();
152+
expect(result.current.instance).toBeNull();
153+
expect(result.current.error).toBeInstanceOf(Error);
154+
expect(result.current.error?.message).toContain('MissingArtboard');
149155
});
150156

151157
it('should throw when artboardName not found and required is true', () => {
@@ -203,7 +209,8 @@ describe('useViewModelInstance - RiveFile with artboardName parameter', () => {
203209
name: 'MainArtboard',
204210
});
205211
expect(mainArtboardViewModel.createDefaultInstance).toHaveBeenCalled();
206-
expect(result.current).toBe(mainInstance);
212+
expect(result.current.instance).toBe(mainInstance);
213+
expect(result.current.error).toBeNull();
207214
});
208215

209216
it('should combine artboardName and instanceName to get specific instance from specific artboard', () => {
@@ -230,7 +237,8 @@ describe('useViewModelInstance - RiveFile with artboardName parameter', () => {
230237
expect(mainArtboardViewModel.createInstanceByName).toHaveBeenCalledWith(
231238
'SpecificInstance'
232239
);
233-
expect(result.current).toBe(specificInstance);
240+
expect(result.current.instance).toBe(specificInstance);
241+
expect(result.current.error).toBeNull();
234242
});
235243
});
236244

@@ -252,10 +260,11 @@ describe('useViewModelInstance - RiveFile with viewModelName parameter', () => {
252260
expect(mockRiveFile.viewModelByName).toHaveBeenCalledWith('Settings');
253261
expect(mockRiveFile.defaultArtboardViewModel).not.toHaveBeenCalled();
254262
expect(settingsViewModel.createDefaultInstance).toHaveBeenCalled();
255-
expect(result.current).toBe(settingsInstance);
263+
expect(result.current.instance).toBe(settingsInstance);
264+
expect(result.current.error).toBeNull();
256265
});
257266

258-
it('should return null when viewModelName not found and required is false', () => {
267+
it('should return error when viewModelName not found and required is false', () => {
259268
const mockRiveFile = createMockRiveFile({
260269
namedViewModels: {},
261270
});
@@ -264,7 +273,9 @@ describe('useViewModelInstance - RiveFile with viewModelName parameter', () => {
264273
useViewModelInstance(mockRiveFile, { viewModelName: 'NonExistent' })
265274
);
266275

267-
expect(result.current).toBeNull();
276+
expect(result.current.instance).toBeNull();
277+
expect(result.current.error).toBeInstanceOf(Error);
278+
expect(result.current.error?.message).toContain('NonExistent');
268279
});
269280

270281
it('should throw when viewModelName not found and required is true', () => {
@@ -303,7 +314,8 @@ describe('useViewModelInstance - RiveFile with viewModelName parameter', () => {
303314
expect(settingsViewModel.createInstanceByName).toHaveBeenCalledWith(
304315
'UserSettings'
305316
);
306-
expect(result.current).toBe(specificInstance);
317+
expect(result.current.instance).toBe(specificInstance);
318+
expect(result.current.error).toBeNull();
307319
});
308320
});
309321

@@ -320,7 +332,8 @@ describe('useViewModelInstance - ViewModel source', () => {
320332

321333
expect(mockViewModel.createInstanceByName).toHaveBeenCalledWith('Gordon');
322334
expect(mockViewModel.createDefaultInstance).not.toHaveBeenCalled();
323-
expect(result.current).toBe(namedInstance);
335+
expect(result.current.instance).toBe(namedInstance);
336+
expect(result.current.error).toBeNull();
324337
});
325338

326339
it('should use createInstance when useNew is true', () => {
@@ -334,7 +347,8 @@ describe('useViewModelInstance - ViewModel source', () => {
334347

335348
expect(mockViewModel.createInstance).toHaveBeenCalled();
336349
expect(mockViewModel.createDefaultInstance).not.toHaveBeenCalled();
337-
expect(result.current).toBe(newInstance);
350+
expect(result.current.instance).toBe(newInstance);
351+
expect(result.current.error).toBeNull();
338352
});
339353

340354
it('should use createDefaultInstance when no params provided', () => {
@@ -344,6 +358,16 @@ describe('useViewModelInstance - ViewModel source', () => {
344358
const { result } = renderHook(() => useViewModelInstance(mockViewModel));
345359

346360
expect(mockViewModel.createDefaultInstance).toHaveBeenCalled();
347-
expect(result.current).toBe(defaultInstance);
361+
expect(result.current.instance).toBe(defaultInstance);
362+
expect(result.current.error).toBeNull();
363+
});
364+
});
365+
366+
describe('useViewModelInstance - null source', () => {
367+
it('should return undefined instance when source is null', () => {
368+
const { result } = renderHook(() => useViewModelInstance(null));
369+
370+
expect(result.current.instance).toBeUndefined();
371+
expect(result.current.error).toBeNull();
348372
});
349373
});

0 commit comments

Comments
 (0)