Skip to content

Commit 89c925b

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 89c925b

2 files changed

Lines changed: 52 additions & 2 deletions

File tree

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)