Skip to content

Commit 232abda

Browse files
committed
fix: prevent infinite re-renders with unstable useRiveFile input
Use stable inputKey derived from URI string instead of object reference as effect dependency.
1 parent 6052279 commit 232abda

4 files changed

Lines changed: 102 additions & 2 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
'use no memo';
2+
3+
import { View, StyleSheet } from 'react-native';
4+
import { RiveView, useRiveFile, Fit } from '@rive-app/react-native';
5+
import { type Metadata } from '../helpers/metadata';
6+
import { useRef } from 'react';
7+
8+
export default function Issue99Reproducer() {
9+
const renderCount = useRef(0);
10+
renderCount.current++;
11+
console.log('Issue99Reproducer render count:', renderCount.current);
12+
13+
// Previously this would cause infinite re-renders because inline objects
14+
// create new references each render. Fixed by using stable inputKey in useRiveFile.
15+
const { riveFile } = useRiveFile({
16+
uri: 'https://cdn.rive.app/animations/vehicles.riv',
17+
});
18+
19+
return (
20+
<View style={styles.container}>
21+
{riveFile && (
22+
<RiveView
23+
autoPlay={true}
24+
fit={Fit.Contain}
25+
file={riveFile}
26+
onError={(error) => console.error('Rive error:', error.message)}
27+
style={styles.rive}
28+
/>
29+
)}
30+
</View>
31+
);
32+
}
33+
34+
Issue99Reproducer.metadata = {
35+
name: 'Issue 99 Reproducer',
36+
description: 'Reproducer for maximum update depth exceeded issue',
37+
} satisfies Metadata;
38+
39+
const styles = StyleSheet.create({
40+
container: {
41+
flex: 1,
42+
alignItems: 'center',
43+
justifyContent: 'center',
44+
},
45+
rive: {
46+
width: '100%',
47+
height: '100%',
48+
},
49+
});

example/src/pages/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export { default as ManyViewModels } from './ManyViewModels';
1111
export { default as ResponsiveLayouts } from './ResponsiveLayouts';
1212
export { default as SharedValueListenerExample } from './SharedValueListenerExample';
1313
export { default as MenuListExample } from './MenuListExample';
14+
export { default as Issue99Reproducer } from './Issue99Reproducer';

src/hooks/__tests__/useRiveFile.test.ts

Lines changed: 43 additions & 1 deletion
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,6 +8,48 @@ jest.mock('react-native/Libraries/Image/Image', () => ({
88
})),
99
}));
1010

11+
describe('useRiveFile - input stability', () => {
12+
const mockRiveFile: RiveFile = {
13+
dispose: jest.fn(),
14+
updateReferencedAssets: jest.fn(),
15+
viewModelCount: 0,
16+
viewModelByIndex: jest.fn(),
17+
viewModelByName: jest.fn(),
18+
defaultArtboardViewModel: jest.fn(),
19+
} as any;
20+
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
(global as any).mockRiveFileFactory.fromSource.mockResolvedValue(
24+
mockRiveFile
25+
);
26+
});
27+
28+
it('should not reload file when input object reference changes but uri is the same', async () => {
29+
const { result, rerender } = renderHook(
30+
(props: { input: { uri: string } }) => useRiveFile(props.input),
31+
{ initialProps: { input: { uri: 'https://example.com/animation.riv' } } }
32+
);
33+
34+
await waitFor(() => {
35+
expect((result.current as any).isLoading).toBe(false);
36+
});
37+
38+
const callCountBefore = (global as any).mockRiveFileFactory.fromSource.mock
39+
.calls.length;
40+
41+
await act(async () => {
42+
rerender({ input: { uri: 'https://example.com/animation.riv' } });
43+
// Give async effect time to run
44+
await new Promise((resolve) => setTimeout(resolve, 50));
45+
});
46+
47+
const callCountAfter = (global as any).mockRiveFileFactory.fromSource.mock
48+
.calls.length;
49+
expect(callCountAfter).toBe(callCountBefore);
50+
});
51+
});
52+
1153
describe('useRiveFile - updateReferencedAssets', () => {
1254
const mockRiveFile: RiveFile = {
1355
dispose: jest.fn(),

src/hooks/useRiveFile.ts

Lines changed: 9 additions & 1 deletion
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
};
@@ -100,6 +106,7 @@ export function useRiveFile(
100106
[options.referencedAssets]
101107
);
102108
const initialReferencedAssets = useRef(referencedAssets);
109+
const inputKey = isUriInput(input) ? input.uri : input;
103110

104111
useEffect(() => {
105112
let currentFile: RiveFile | null = null;
@@ -164,7 +171,8 @@ export function useRiveFile(
164171
callDispose(currentFile);
165172
}
166173
};
167-
}, [input]);
174+
// eslint-disable-next-line react-hooks/exhaustive-deps -- inputKey is derived from input, using input directly would cause infinite loops with inline objects
175+
}, [inputKey]);
168176

169177
const { riveFile } = result;
170178
useEffect(() => {

0 commit comments

Comments
 (0)