Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions example/__tests__/use-rive-trigger.harness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import {
describe,
it,
expect,
render,
waitFor,
cleanup,
} from 'react-native-harness';
import { useEffect, useState } from 'react';
import { View } from 'react-native';
import {
Fit,
RiveFileFactory,
RiveView,
useRiveTrigger,
type RiveFile,
} from '@rive-app/react-native';
import type { ViewModelInstance } from '@rive-app/react-native';

const DATABINDING = require('../assets/rive/databinding.riv');

function expectDefined<T>(value: T): asserts value is NonNullable<T> {
expect(value).toBeDefined();
}

async function loadGordonInstance() {
const file = await RiveFileFactory.fromSource(DATABINDING, undefined);
const vm = file.viewModelByName('Person');
expectDefined(vm);
const instance = vm.createInstanceByName('Gordon');
expectDefined(instance);
return { file, instance };
}

// ─── Test context types ────────────────────────────────────────────

type TriggerContext = {
triggerCount: number;
triggerFn: (() => void) | null;
error: Error | null;
renderCount: number;
};

function createTriggerContext(): TriggerContext {
return { triggerCount: 0, triggerFn: null, error: null, renderCount: 0 };
}

// ─── Test component: stable callback ───────────────────────────────
// RiveView with dataBind is required so the Rive render loop runs
// and pollChanges() dispatches trigger events to listeners.

function StableTriggerComponent({
file,
instance,
context,
}: {
file: RiveFile;
instance: ViewModelInstance;
context: TriggerContext;
}) {
context.renderCount++;

const { trigger, error } = useRiveTrigger('jump', instance, {
onTrigger: () => {
context.triggerCount++;
},
});

useEffect(() => {
context.triggerFn = trigger;
context.error = error;
}, [context, trigger, error]);

return (
<View style={{ width: 200, height: 200 }}>
<RiveView
file={file}
fit={Fit.Contain}
dataBind={instance}
style={{ flex: 1 }}
/>
</View>
);
}

// ─── Test component: unstable callback (issue #230) ────────────────

function UnstableTriggerComponent({
file,
instance,
context,
}: {
file: RiveFile;
instance: ViewModelInstance;
context: TriggerContext;
}) {
'use no memo';

const [, setTick] = useState(0);
context.renderCount++;

const onTrigger = () => {
context.triggerCount++;
};

const { trigger, error } = useRiveTrigger('jump', instance, { onTrigger });

useEffect(() => {
context.triggerFn = trigger;
context.error = error;
}, [context, trigger, error]);

// Force re-renders to change callback identity
useEffect(() => {
const interval = setInterval(() => setTick((t) => t + 1), 50);
const timeout = setTimeout(() => clearInterval(interval), 300);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
}, []);

return (
<View style={{ width: 200, height: 200 }}>
<RiveView
file={file}
fit={Fit.Contain}
dataBind={instance}
style={{ flex: 1 }}
/>
</View>
);
}

// ─── Tests ─────────────────────────────────────────────────────────

describe('useRiveTrigger hook', () => {
it('receives trigger events from JS trigger()', async () => {
const { file, instance } = await loadGordonInstance();
const context = createTriggerContext();

await render(
<StableTriggerComponent
file={file}
instance={instance}
context={context}
/>
);

await waitFor(
() => {
expect(context.triggerFn).not.toBeNull();
},
{ timeout: 3000 }
);

expect(context.error).toBeNull();

// Fire trigger and wait for it — pollChanges() runs on frame ticks,
// so we wait for each trigger individually to avoid coalescing.
context.triggerFn!();

await waitFor(
() => {
expect(context.triggerCount).toBeGreaterThanOrEqual(1);
},
{ timeout: 5000 }
);

cleanup();
});

it('receives triggers with unstable callback after re-renders (#230)', async () => {
const { file, instance } = await loadGordonInstance();
const context = createTriggerContext();

await render(
<UnstableTriggerComponent
file={file}
instance={instance}
context={context}
/>
);

// Wait for the re-render burst to complete (300ms of re-renders every 50ms)
await waitFor(
() => {
expect(context.renderCount).toBeGreaterThanOrEqual(3);
},
{ timeout: 2000 }
);

await waitFor(
() => {
expect(context.triggerFn).not.toBeNull();
},
{ timeout: 3000 }
);

expect(context.error).toBeNull();

// Fire trigger AFTER the re-render burst — before the fix, this was lost
context.triggerFn!();

await waitFor(
() => {
expect(context.triggerCount).toBeGreaterThanOrEqual(1);
},
{ timeout: 5000 }
);

cleanup();
});
});
72 changes: 45 additions & 27 deletions src/hooks/__tests__/useRiveProperty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@ describe('useRiveProperty', () => {
});

const { result } = renderHook(() =>
useRiveProperty<any, string>(mockInstance, 'favDrink/type', {
getProperty: (vmi, path) => (vmi as any).enumProperty(path),
})
useRiveProperty<any, string>(
mockInstance,
'favDrink/type',
(vmi: any, path: string) => vmi.enumProperty(path)
)
);

// The mock's addListener emits 'Tea' synchronously — React batches it with the
Expand All @@ -66,9 +68,11 @@ describe('useRiveProperty', () => {
});

const { result } = renderHook(() =>
useRiveProperty<any, string>(mockInstance, 'favDrink/type', {
getProperty: (vmi, path) => (vmi as any).enumProperty(path),
})
useRiveProperty<any, string>(
mockInstance,
'favDrink/type',
(vmi: any, path: string) => vmi.enumProperty(path)
)
);

act(() => {
Expand All @@ -81,9 +85,11 @@ describe('useRiveProperty', () => {

it('should return undefined when viewModelInstance is null', () => {
const { result } = renderHook(() =>
useRiveProperty<any, string>(null, 'favDrink/type', {
getProperty: (vmi, path) => (vmi as any).enumProperty(path),
})
useRiveProperty<any, string>(
null,
'favDrink/type',
(vmi: any, path: string) => vmi.enumProperty(path)
)
);

const [value] = result.current;
Expand All @@ -94,9 +100,11 @@ describe('useRiveProperty', () => {
const mockInstance = createMockViewModelInstance({});

const { result } = renderHook(() =>
useRiveProperty<any, string>(mockInstance, 'nonexistent/path', {
getProperty: (vmi, path) => (vmi as any).enumProperty(path),
})
useRiveProperty<any, string>(
mockInstance,
'nonexistent/path',
(vmi: any, path: string) => vmi.enumProperty(path)
)
);

const [, , error] = result.current;
Expand All @@ -108,9 +116,11 @@ describe('useRiveProperty', () => {
const mockInstance = createMockViewModelInstance({});

const { result } = renderHook(() =>
useRiveProperty<any, string>(mockInstance, 'nonexistent/path', {
getProperty: (vmi, path) => (vmi as any).enumProperty(path),
})
useRiveProperty<any, string>(
mockInstance,
'nonexistent/path',
(vmi: any, path: string) => vmi.enumProperty(path)
)
);

// Error already set by useEffect (property not found on valid instance)
Expand All @@ -131,9 +141,11 @@ describe('useRiveProperty', () => {
// Start with undefined instance (simulates async file loading)
const { result } = renderHook(
(props: { instance: ViewModelInstance | undefined }) =>
useRiveProperty<any, string>(props.instance, 'text', {
getProperty: (vmi, path) => (vmi as any).stringProperty(path),
}),
useRiveProperty<any, string>(
props.instance,
'text',
(vmi: any, path: string) => vmi.stringProperty(path)
),
{ initialProps: { instance: undefined } }
);

Expand All @@ -156,9 +168,11 @@ describe('useRiveProperty', () => {
// Start with undefined instance
const { result, rerender } = renderHook(
(props: { instance: ViewModelInstance | undefined }) =>
useRiveProperty<any, string>(props.instance, 'text', {
getProperty: (vmi, path) => (vmi as any).stringProperty(path),
}),
useRiveProperty<any, string>(
props.instance,
'text',
(vmi: any, path: string) => vmi.stringProperty(path)
),
{ initialProps: { instance: undefined } }
);

Expand Down Expand Up @@ -197,9 +211,11 @@ describe('useRiveProperty', () => {

const { result, rerender } = renderHook(
(props: { path: string }) =>
useRiveProperty<any, string>(mockInstance, props.path, {
getProperty: (vmi, p) => (vmi as any).enumProperty(p),
}),
useRiveProperty<any, string>(
mockInstance,
props.path,
(vmi: any, p: string) => vmi.enumProperty(p)
),
{ initialProps: { path: 'drinks/tea' } }
);

Expand All @@ -222,9 +238,11 @@ describe('useRiveProperty', () => {

const { result, rerender } = renderHook(
(props: { instance: ViewModelInstance }) =>
useRiveProperty<any, string>(props.instance, 'prop/path', {
getProperty: (vmi, p) => (vmi as any).enumProperty(p),
}),
useRiveProperty<any, string>(
props.instance,
'prop/path',
(vmi: any, p: string) => vmi.enumProperty(p)
),
{ initialProps: { instance: mockInstance1 } }
);

Expand Down
7 changes: 3 additions & 4 deletions src/hooks/useRiveBoolean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ import {
import type { UseRivePropertyResult } from '../types';
import { useRiveProperty } from './useRiveProperty';

const BOOLEAN_PROPERTY_OPTIONS = {
getProperty: (vmi: ViewModelInstance, p: string) => vmi.booleanProperty(p),
};
const getBooleanProperty = (vmi: ViewModelInstance, p: string) =>
vmi.booleanProperty(p);

/**
* Hook for interacting with boolean ViewModel instance properties.
Expand All @@ -23,6 +22,6 @@ export function useRiveBoolean(
const [value, setValue, error] = useRiveProperty<
ViewModelBooleanProperty,
boolean
>(viewModelInstance, path, BOOLEAN_PROPERTY_OPTIONS);
>(viewModelInstance, path, getBooleanProperty);
return { value, setValue, error };
}
7 changes: 3 additions & 4 deletions src/hooks/useRiveColor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ import type {
import { useRiveProperty } from './useRiveProperty';
import { RiveColor } from '../core/RiveColor';

const COLOR_PROPERTY_OPTIONS = {
getProperty: (vmi: ViewModelInstance, p: string) => vmi.colorProperty(p),
};
const getColorProperty = (vmi: ViewModelInstance, p: string) =>
vmi.colorProperty(p);

export interface UseRiveColorResult {
value: RiveColor | undefined;
Expand All @@ -30,7 +29,7 @@ export function useRiveColor(
const [rawValue, setRawValue, error] = useRiveProperty<
ViewModelColorProperty,
number
>(viewModelInstance, path, COLOR_PROPERTY_OPTIONS);
>(viewModelInstance, path, getColorProperty);

const value =
rawValue !== undefined ? RiveColor.fromInt(rawValue) : undefined;
Expand Down
Loading
Loading