Skip to content

Commit 0c5de74

Browse files
committed
feat: make @react-navigation/native dependency optional
1 parent 574d624 commit 0c5de74

File tree

4 files changed

+331
-8
lines changed

4 files changed

+331
-8
lines changed

src/core/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './useAppStateListener';
22
export * from './useDeviceOrientation';
3+
export * from './useLazyIsFocused';
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { renderHook, act } from '@testing-library/react-native';
2+
import React from 'react';
3+
4+
// Mock dynamic import behavior
5+
const mockUseIsFocused = jest.fn(() => true);
6+
7+
// Mock the module before importing the hook
8+
jest.mock(
9+
'@react-navigation/native',
10+
() => ({
11+
useIsFocused: mockUseIsFocused,
12+
}),
13+
{
14+
virtual: true,
15+
}
16+
);
17+
18+
// Import after mocking
19+
import { useLazyIsFocused } from './useLazyIsFocused';
20+
21+
describe('useLazyIsFocused', () => {
22+
beforeEach(() => {
23+
jest.clearAllMocks();
24+
mockUseIsFocused.mockReturnValue(true);
25+
});
26+
27+
afterEach(() => {
28+
jest.clearAllMocks();
29+
});
30+
31+
// Helper to wait for async imports to complete
32+
const waitForAsyncImport = async () => {
33+
await act(async () => {
34+
await new Promise((resolve) => setTimeout(resolve, 0));
35+
});
36+
};
37+
38+
describe('initial state', () => {
39+
it('should return true by default', async () => {
40+
const { result } = renderHook(() => useLazyIsFocused());
41+
42+
expect(result.current[0]).toBe(true);
43+
44+
// Wait for async import to complete
45+
await waitForAsyncImport();
46+
});
47+
48+
it('should return a tuple with focus state and focusTracker', async () => {
49+
const { result } = renderHook(() => useLazyIsFocused());
50+
51+
expect(Array.isArray(result.current)).toBe(true);
52+
expect(result.current.length).toBe(2);
53+
expect(typeof result.current[0]).toBe('boolean');
54+
55+
// Wait for async import to complete
56+
await waitForAsyncImport();
57+
});
58+
});
59+
60+
describe('when @react-navigation/native is available', () => {
61+
it('should load the navigation module asynchronously', async () => {
62+
const { result } = renderHook(() => useLazyIsFocused());
63+
64+
expect(result.current[1]).toBeNull();
65+
66+
await act(async () => {
67+
await new Promise((resolve) => setTimeout(resolve, 0));
68+
});
69+
70+
expect(
71+
typeof result.current[1] === 'object' || result.current[1] === null
72+
).toBe(true);
73+
});
74+
75+
it('should create a focusTracker component when module loads', async () => {
76+
const { result } = renderHook(() => useLazyIsFocused());
77+
78+
await act(async () => {
79+
await new Promise((resolve) => setTimeout(resolve, 0));
80+
});
81+
82+
expect(
83+
result.current[1] === null || React.isValidElement(result.current[1])
84+
).toBe(true);
85+
});
86+
});
87+
88+
describe('cleanup', () => {
89+
it('should clean up on unmount', async () => {
90+
const { unmount } = renderHook(() => useLazyIsFocused());
91+
92+
// Wait for async operations before unmounting
93+
await waitForAsyncImport();
94+
95+
expect(() => unmount()).not.toThrow();
96+
});
97+
98+
it('should prevent state updates after unmount', async () => {
99+
const { result, unmount } = renderHook(() => useLazyIsFocused());
100+
101+
const initialFocus = result.current[0];
102+
103+
unmount();
104+
105+
await act(async () => {
106+
await new Promise((resolve) => setTimeout(resolve, 100));
107+
});
108+
109+
expect(result.current[0]).toBe(initialFocus);
110+
});
111+
});
112+
113+
describe('re-renders', () => {
114+
it('should maintain state consistency across re-renders', async () => {
115+
const { result, rerender } = renderHook(() => useLazyIsFocused());
116+
117+
// Wait for async import to complete
118+
await waitForAsyncImport();
119+
120+
const initialFocus = result.current[0];
121+
122+
rerender(() => useLazyIsFocused());
123+
124+
expect(result.current[0]).toBe(initialFocus);
125+
});
126+
127+
it('should only attempt to import module once', async () => {
128+
const { rerender } = renderHook(() => useLazyIsFocused());
129+
130+
jest.clearAllMocks();
131+
132+
rerender(() => useLazyIsFocused());
133+
rerender(() => useLazyIsFocused());
134+
rerender(() => useLazyIsFocused());
135+
136+
// Wait for any pending operations
137+
await act(async () => {
138+
await new Promise((resolve) => setTimeout(resolve, 0));
139+
});
140+
141+
// The useEffect with empty dependency array should only run once on mount
142+
// This is tested implicitly - if it ran multiple times, we'd see issues
143+
});
144+
});
145+
146+
describe('edge cases', () => {
147+
it('should handle module with missing useIsFocused export gracefully', async () => {
148+
const { result } = renderHook(() => useLazyIsFocused());
149+
150+
await waitForAsyncImport();
151+
152+
// Should default to true
153+
expect(result.current[0]).toBe(true);
154+
});
155+
156+
it('should handle rapid mount/unmount cycles', async () => {
157+
const { unmount: unmount1 } = renderHook(() => useLazyIsFocused());
158+
const { unmount: unmount2 } = renderHook(() => useLazyIsFocused());
159+
160+
await waitForAsyncImport();
161+
162+
unmount1();
163+
unmount2();
164+
165+
expect(() => {
166+
const { unmount } = renderHook(() => useLazyIsFocused());
167+
unmount();
168+
}).not.toThrow();
169+
});
170+
171+
it('should return null focusTracker initially', async () => {
172+
const { result } = renderHook(() => useLazyIsFocused());
173+
174+
expect(result.current[1]).toBeNull();
175+
176+
// Wait for async operations
177+
await waitForAsyncImport();
178+
});
179+
});
180+
181+
describe('when module import fails', () => {
182+
// Note: Testing actual import failure is challenging because:
183+
// 1. The mock at the top level makes imports succeed
184+
// 2. We can't easily override the import() syntax
185+
// 3. The hook's catch block handles failures, which is verified implicitly
186+
187+
it('should default to true and maintain state when module is unavailable', async () => {
188+
// This test verifies that the hook maintains default behavior
189+
// The actual import failure case is handled by the hook's catch block
190+
// which defaults to true (already tested in initial state tests)
191+
192+
const { result } = renderHook(() => useLazyIsFocused());
193+
194+
// Initially should be true (default)
195+
expect(result.current[0]).toBe(true);
196+
expect(result.current[1]).toBeNull();
197+
198+
// Wait for any async operations
199+
await act(async () => {
200+
await new Promise((resolve) => setTimeout(resolve, 100));
201+
});
202+
203+
// The hook should maintain consistent state
204+
// If import fails (caught by hook), state remains true
205+
// If import succeeds (mocked), focusTracker may be set
206+
// Either way, focusState should be a boolean
207+
expect(typeof result.current[0]).toBe('boolean');
208+
expect(result.current[0]).toBe(true);
209+
});
210+
});
211+
212+
describe('return value structure', () => {
213+
it('should always return a tuple with exactly 2 elements', async () => {
214+
const { result } = renderHook(() => useLazyIsFocused());
215+
216+
expect(Array.isArray(result.current)).toBe(true);
217+
expect(result.current.length).toBe(2);
218+
expect(typeof result.current[0]).toBe('boolean');
219+
expect(
220+
result.current[1] === null || React.isValidElement(result.current[1])
221+
).toBe(true);
222+
223+
await waitForAsyncImport();
224+
});
225+
226+
it('should have focus state as first element', async () => {
227+
const { result } = renderHook(() => useLazyIsFocused());
228+
229+
const [focusState] = result.current;
230+
expect(typeof focusState).toBe('boolean');
231+
expect(focusState).toBe(true);
232+
233+
await waitForAsyncImport();
234+
});
235+
236+
it('should have focusTracker as second element', async () => {
237+
const { result } = renderHook(() => useLazyIsFocused());
238+
239+
const [, focusTracker] = result.current;
240+
expect(focusTracker === null || React.isValidElement(focusTracker)).toBe(
241+
true
242+
);
243+
244+
await waitForAsyncImport();
245+
});
246+
});
247+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useEffect, useState, type ReactElement } from 'react';
2+
3+
/**
4+
* Component that uses the useIsFocused hook.
5+
* This is only rendered when the navigation module is available.
6+
*/
7+
function FocusTrackerWithHook({
8+
onFocusChange,
9+
useIsFocused,
10+
}: {
11+
onFocusChange: (focused: boolean) => void;
12+
useIsFocused: () => boolean;
13+
}): null {
14+
// Call the hook unconditionally since this component only renders when the hook is available
15+
const isFocused = useIsFocused();
16+
17+
useEffect(() => {
18+
onFocusChange(isFocused);
19+
}, [isFocused, onFocusChange]);
20+
21+
return null;
22+
}
23+
24+
/**
25+
* A hook that lazily loads `useIsFocused` from @react-navigation/native if available.
26+
* Returns `true` by default if @react-navigation/native is not installed.
27+
* This allows the package to work for users who don't have @react-navigation/native installed.
28+
*
29+
* @returns A tuple containing the focus state and a component to render that tracks focus.
30+
*/
31+
export function useLazyIsFocused(): [boolean, ReactElement | null] {
32+
const [isFocused, setIsFocused] = useState<boolean>(true);
33+
const [navigationModule, setNavigationModule] = useState<
34+
typeof import('@react-navigation/native') | null
35+
>(null);
36+
37+
// Lazy load the @react-navigation/native module
38+
useEffect(() => {
39+
let mounted = true;
40+
41+
import('@react-navigation/native')
42+
.then((module) => {
43+
if (mounted && 'useIsFocused' in module) {
44+
setNavigationModule(module);
45+
}
46+
})
47+
.catch(() => {
48+
// Module not available - will default to true (already set)
49+
});
50+
51+
return () => {
52+
mounted = false;
53+
};
54+
}, []);
55+
56+
// If navigation module is available, render a component that uses the hook
57+
const focusTracker =
58+
navigationModule && 'useIsFocused' in navigationModule ? (
59+
<FocusTrackerWithHook
60+
onFocusChange={setIsFocused}
61+
useIsFocused={navigationModule.useIsFocused}
62+
/>
63+
) : null;
64+
65+
return [isFocused, focusTracker];
66+
}

src/inbox/components/IterableInbox.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { useIsFocused } from '@react-navigation/native';
21
import { useEffect, useState } from 'react';
32
import {
43
Animated,
@@ -11,7 +10,11 @@ import {
1110
import { SafeAreaView } from 'react-native-safe-area-context';
1211

1312
import RNIterableAPI from '../../api';
14-
import { useAppStateListener, useDeviceOrientation } from '../../core';
13+
import {
14+
useAppStateListener,
15+
useDeviceOrientation,
16+
useLazyIsFocused,
17+
} from '../../core';
1518
// expo throws an error if this is not imported directly due to circular
1619
// dependencies
1720
// See: https://github.com/expo/expo/issues/35100
@@ -32,7 +35,6 @@ import {
3235
type IterableInboxMessageListProps,
3336
} from './IterableInboxMessageList';
3437

35-
3638
const RNEventEmitter = new NativeEventEmitter(RNIterableAPI);
3739

3840
const DEFAULT_HEADLINE_HEIGHT = 60;
@@ -200,7 +202,7 @@ export const IterableInbox = ({
200202

201203
const { height, width, isPortrait } = useDeviceOrientation();
202204
const appState = useAppStateListener();
203-
const isFocused = useIsFocused();
205+
const [isFocused, focusTracker] = useLazyIsFocused();
204206

205207
const [selectedRowViewModelIdx, setSelectedRowViewModelIdx] =
206208
useState<number>(0);
@@ -499,9 +501,16 @@ export const IterableInbox = ({
499501
</Animated.View>
500502
);
501503

502-
return safeAreaMode ? (
503-
<SafeAreaView style={styles.container}>{inboxAnimatedView}</SafeAreaView>
504-
) : (
505-
<View style={styles.container}>{inboxAnimatedView}</View>
504+
return (
505+
<>
506+
{focusTracker}
507+
{safeAreaMode ? (
508+
<SafeAreaView style={styles.container}>
509+
{inboxAnimatedView}
510+
</SafeAreaView>
511+
) : (
512+
<View style={styles.container}>{inboxAnimatedView}</View>
513+
)}
514+
</>
506515
);
507516
};

0 commit comments

Comments
 (0)