Skip to content

Commit c70e0d7

Browse files
authored
fix: prevent infinite re-renders with unstable useRiveFile input (#100)
## Summary - `useRiveFile({uri: 'https://...'})` caused infinite re-renders as the input was not stable, causing useEffect to run, which caused setResult(..), which triggered another render... Fixes #99 **Note:** Includes #101 (CI: ignore nitrogen copyright year changes) ## Test plan - [x] `yarn test` passes - [x] `yarn typecheck` passes - [x] `yarn lint` passes <details> <summary>Reproducer (not checked in)</summary> ```tsx 'use no memo'; import { View, StyleSheet } from 'react-native'; import { RiveView, useRiveFile, Fit } from '@rive-app/react-native'; import { useRef } from 'react'; export default function Issue99Reproducer() { const renderCount = useRef(0); renderCount.current++; console.log('Issue99Reproducer render count:', renderCount.current); // Previously this would cause infinite re-renders because inline objects // create new references each render. Fixed by using stable inputKey in useRiveFile. const { riveFile } = useRiveFile({ uri: 'https://cdn.rive.app/animations/vehicles.riv', }); return ( <View style={styles.container}> {riveFile && ( <RiveView autoPlay={true} fit={Fit.Contain} file={riveFile} onError={(error) => console.error('Rive error:', error.message)} style={styles.rive} /> )} </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', }, rive: { width: '100%', height: '100%', }, }); ``` </details>
1 parent a536df2 commit c70e0d7

2 files changed

Lines changed: 97 additions & 5 deletions

File tree

src/hooks/__tests__/useRiveFile.test.ts

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { renderHook, waitFor } from '@testing-library/react-native';
1+
import { renderHook, waitFor, act } from '@testing-library/react-native';
22
import { useRiveFile } from '../useRiveFile';
33
import type { RiveFile } from '../../specs/RiveFile.nitro';
44

@@ -8,15 +8,95 @@ jest.mock('react-native/Libraries/Image/Image', () => ({
88
})),
99
}));
1010

11-
describe('useRiveFile - updateReferencedAssets', () => {
12-
const mockRiveFile: RiveFile = {
11+
function createMockRiveFile(): RiveFile {
12+
return {
1313
dispose: jest.fn(),
1414
updateReferencedAssets: jest.fn(),
1515
viewModelCount: 0,
1616
viewModelByIndex: jest.fn(),
1717
viewModelByName: jest.fn(),
1818
defaultArtboardViewModel: jest.fn(),
1919
} as any;
20+
}
21+
22+
describe('useRiveFile - input stability', () => {
23+
const mockRiveFile = createMockRiveFile();
24+
25+
beforeEach(() => {
26+
jest.clearAllMocks();
27+
// fromSource internally calls fromURL for http(s) URIs
28+
(global as any).mockRiveFileFactory.fromURL.mockResolvedValue(mockRiveFile);
29+
});
30+
31+
it('should not reload file when input object reference changes but uri is the same', async () => {
32+
const { result, rerender } = renderHook(
33+
(props: { input: { uri: string } }) => useRiveFile(props.input),
34+
{ initialProps: { input: { uri: 'https://example.com/animation.riv' } } }
35+
);
36+
37+
await waitFor(() => {
38+
expect((result.current as any).isLoading).toBe(false);
39+
});
40+
41+
const callCountBefore = (global as any).mockRiveFileFactory.fromURL.mock
42+
.calls.length;
43+
44+
await act(async () => {
45+
// Pass NEW object reference with SAME uri value
46+
rerender({ input: { uri: 'https://example.com/animation.riv' } });
47+
await new Promise((resolve) => setTimeout(resolve, 50));
48+
});
49+
50+
const callCountAfter = (global as any).mockRiveFileFactory.fromURL.mock
51+
.calls.length;
52+
expect(callCountAfter).toBe(callCountBefore);
53+
});
54+
55+
it('should stabilize after initial load when called with inline object', async () => {
56+
let renderCount = 0;
57+
const MAX_RENDERS = 10;
58+
const url = 'https://example.com/animation.riv';
59+
60+
const { result, rerender } = renderHook(() => {
61+
renderCount++;
62+
if (renderCount > MAX_RENDERS) {
63+
throw new Error(
64+
`Infinite re-render detected: ${renderCount} renders exceeded max of ${MAX_RENDERS}`
65+
);
66+
}
67+
// Simulate inline object creation (new reference each render)
68+
return useRiveFile({ uri: url });
69+
}, {});
70+
71+
// First render: isLoading=true
72+
expect(renderCount).toBe(1);
73+
expect(result.current.isLoading).toBe(true);
74+
75+
// Wait for file to load - this triggers setState and a re-render
76+
await waitFor(() => {
77+
expect(result.current.isLoading).toBe(false);
78+
});
79+
80+
const renderCountAfterLoad = renderCount;
81+
82+
// Simulate parent re-render (which creates new inline object)
83+
await act(async () => {
84+
rerender({});
85+
await new Promise((resolve) => setTimeout(resolve, 50));
86+
});
87+
88+
// Should only have 1 additional render from rerender(), no cascading re-renders
89+
expect(renderCount).toBe(renderCountAfterLoad + 1);
90+
91+
// File should not have been reloaded
92+
expect((global as any).mockRiveFileFactory.fromURL.mock.calls.length).toBe(
93+
1
94+
);
95+
});
96+
});
97+
98+
describe('useRiveFile - updateReferencedAssets', () => {
99+
const mockRiveFile = createMockRiveFile();
20100

21101
beforeEach(() => {
22102
jest.clearAllMocks();

src/hooks/useRiveFile.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import type {
1616
export type { ReferencedAssets, ResolvedReferencedAssets };
1717
export type RiveFileInput = number | { uri: string } | string | ArrayBuffer;
1818

19+
function isUriInput(
20+
input: RiveFileInput | undefined
21+
): input is { uri: string } {
22+
return input != null && typeof input === 'object' && 'uri' in input;
23+
}
24+
1925
export type UseRiveFileOptions = {
2026
referencedAssets?: ReferencedAssets;
2127
};
@@ -101,12 +107,18 @@ export function useRiveFile(
101107
);
102108
const initialReferencedAssets = useRef(referencedAssets);
103109

110+
const inputKind = isUriInput(input) ? 'uri' : 'primitive';
111+
const inputValue = isUriInput(input) ? input.uri : input;
112+
104113
useEffect(() => {
105114
let currentFile: RiveFile | null = null;
106115

107116
const loadRiveFile = async () => {
108117
try {
109-
const currentInput = input;
118+
const currentInput =
119+
inputKind === 'uri'
120+
? { uri: inputValue as string }
121+
: (inputValue as Exclude<RiveFileInput, { uri: string }>);
110122

111123
if (currentInput == null) {
112124
setResult({
@@ -164,7 +176,7 @@ export function useRiveFile(
164176
callDispose(currentFile);
165177
}
166178
};
167-
}, [input]);
179+
}, [inputKind, inputValue]);
168180

169181
const { riveFile } = result;
170182
useEffect(() => {

0 commit comments

Comments
 (0)