Skip to content

Commit 3d76089

Browse files
authored
Merge pull request Expensify#92914 from callstack-internal/refactor/extract-inbox-tab-span-lifecycle
[No QA] refactor: extract inbox tab span lifecycle hook
2 parents 4a84762 + 86224ee commit 3d76089

3 files changed

Lines changed: 202 additions & 39 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {useFocusEffect} from '@react-navigation/native';
2+
import {useCallback, useEffect, useRef} from 'react';
3+
import {cancelSpan, endSpan, getSpan} from '@libs/telemetry/activeSpans';
4+
import CONST from '@src/CONST';
5+
6+
/**
7+
* Manages the ManualNavigateToInboxTab span lifecycle for the inbox sidebar.
8+
*
9+
* Three signals are handled:
10+
* - onLayout fires on first mount: ends the span (normal path).
11+
* - useFocusEffect fires on re-focus when react-freeze has cached the layout: ends the span (warm path).
12+
* The blur cleanup cancels any orphaned span when the user navigates away before layout completes.
13+
* - useEffect unmount cleanup cancels the span only if layout never completed AND the active span
14+
* is the same one that was present when this instance mounted (avoids canceling a span started
15+
* by a subsequent tab click).
16+
*
17+
* Returns `onLayout` to be attached to the sidebar container View.
18+
*/
19+
function useInboxTabSpanLifecycle(): () => void {
20+
const hasHadFirstLayout = useRef(false);
21+
const spanOnMount = useRef(getSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB));
22+
23+
const onLayout = useCallback(() => {
24+
hasHadFirstLayout.current = true;
25+
endSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB);
26+
spanOnMount.current = undefined;
27+
}, []);
28+
29+
// Focus: ends span on re-visits (react-freeze cached layout, onLayout won't fire again).
30+
// Blur cleanup: cancels orphaned span when user navigates away before onLayout fires.
31+
useFocusEffect(
32+
useCallback(() => {
33+
if (hasHadFirstLayout.current) {
34+
endSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB);
35+
}
36+
return () => cancelSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB);
37+
}, []),
38+
);
39+
40+
// Unmount: cancel only if layout never completed AND the active span is
41+
// the same one that existed when this instance mounted (avoids canceling
42+
// a newer span started by a subsequent tab click).
43+
useEffect(
44+
() => () => {
45+
if (hasHadFirstLayout.current) {
46+
return;
47+
}
48+
const activeSpan = getSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB);
49+
if (activeSpan !== spanOnMount.current) {
50+
return;
51+
}
52+
cancelSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB);
53+
},
54+
[],
55+
);
56+
57+
return onLayout;
58+
}
59+
60+
export default useInboxTabSpanLifecycle;

src/pages/inbox/sidebar/SidebarLinksData.tsx

Lines changed: 4 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import {useFocusEffect, useIsFocused} from '@react-navigation/native';
1+
import {useIsFocused} from '@react-navigation/native';
22
import * as Sentry from '@sentry/react-native';
3-
import React, {useCallback, useEffect, useRef} from 'react';
3+
import React, {useCallback, useRef} from 'react';
44
import {View} from 'react-native';
55
import type {EdgeInsets} from 'react-native-safe-area-context';
6+
import useInboxTabSpanLifecycle from '@hooks/useInboxTabSpanLifecycle';
67
import useLocalize from '@hooks/useLocalize';
78
import useOnyx from '@hooks/useOnyx';
89
import {useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports';
910
import useThemeStyles from '@hooks/useThemeStyles';
10-
import {cancelSpan, endSpan, getSpan} from '@libs/telemetry/activeSpans';
1111
import CONST from '@src/CONST';
1212
import ONYXKEYS from '@src/ONYXKEYS';
1313
import SidebarLinks from './SidebarLinks';
@@ -29,42 +29,7 @@ function SidebarLinksData({insets}: SidebarLinksDataProps) {
2929
currentReportIDRef.current = currentReportID;
3030
const isActiveReport = useCallback((reportID: string): boolean => currentReportIDRef.current === reportID, []);
3131

32-
const hasHadFirstLayout = useRef(false);
33-
const spanOnMount = useRef(getSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB));
34-
35-
const onLayout = useCallback(() => {
36-
hasHadFirstLayout.current = true;
37-
endSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB);
38-
spanOnMount.current = undefined;
39-
}, []);
40-
41-
// Focus: ends span on re-visits (react-freeze cached layout, onLayout won't fire again).
42-
// Blur cleanup: cancels orphaned span when user navigates away before onLayout fires.
43-
useFocusEffect(
44-
useCallback(() => {
45-
if (hasHadFirstLayout.current) {
46-
endSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB);
47-
}
48-
return () => cancelSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB);
49-
}, []),
50-
);
51-
52-
// Unmount: cancel only if layout never completed AND the active span is
53-
// the same one that existed when this instance mounted (avoids canceling
54-
// a newer span started by a subsequent tab click).
55-
useEffect(
56-
() => () => {
57-
if (hasHadFirstLayout.current) {
58-
return;
59-
}
60-
const activeSpan = getSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB);
61-
if (activeSpan !== spanOnMount.current) {
62-
return;
63-
}
64-
cancelSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB);
65-
},
66-
[],
67-
);
32+
const onLayout = useInboxTabSpanLifecycle();
6833

6934
return (
7035
<View
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import {act, renderHook} from '@testing-library/react-native';
2+
import useInboxTabSpanLifecycle from '@hooks/useInboxTabSpanLifecycle';
3+
import CONST from '@src/CONST';
4+
5+
type FocusCallback = () => (() => void) | void;
6+
type FakeSpan = {id: string};
7+
8+
const SPAN = CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB;
9+
10+
const mockUseFocusEffect = jest.fn<void, [FocusCallback]>();
11+
const mockGetSpan = jest.fn<FakeSpan | undefined, [string]>();
12+
const mockEndSpan = jest.fn<void, [string]>();
13+
const mockCancelSpan = jest.fn<void, [string]>();
14+
15+
// The hook only consumes useFocusEffect from this module. We capture the focus callback
16+
// (via mockUseFocusEffect.mock.calls) so each test can drive focus/blur explicitly instead
17+
// of standing up a navigation container.
18+
jest.mock('@react-navigation/native', () => ({
19+
useFocusEffect: (callback: FocusCallback) => {
20+
mockUseFocusEffect(callback);
21+
},
22+
}));
23+
24+
jest.mock('@libs/telemetry/activeSpans', () => ({
25+
getSpan: (spanId: string) => mockGetSpan(spanId),
26+
endSpan: (spanId: string) => mockEndSpan(spanId),
27+
cancelSpan: (spanId: string) => mockCancelSpan(spanId),
28+
}));
29+
30+
const spanAtMount: FakeSpan = {id: 'span-at-mount'};
31+
const newerSpan: FakeSpan = {id: 'newer-span'};
32+
33+
/** The currently active span returned by getSpan. Defaults to the one present when the hook mounts. */
34+
let currentActiveSpan: FakeSpan | undefined;
35+
36+
/** Returns the latest focus callback the hook registered with useFocusEffect. */
37+
function getFocusCallback(): FocusCallback {
38+
const callback = mockUseFocusEffect.mock.calls.at(-1)?.[0];
39+
if (!callback) {
40+
throw new Error('useFocusEffect was never called by the hook');
41+
}
42+
return callback;
43+
}
44+
45+
describe('useInboxTabSpanLifecycle', () => {
46+
beforeEach(() => {
47+
jest.clearAllMocks();
48+
currentActiveSpan = spanAtMount;
49+
mockGetSpan.mockImplementation(() => currentActiveSpan);
50+
});
51+
52+
it('ends the span when the sidebar lays out for the first time (normal path)', () => {
53+
const {result} = renderHook(() => useInboxTabSpanLifecycle());
54+
55+
act(() => {
56+
result.current();
57+
});
58+
59+
expect(mockEndSpan).toHaveBeenCalledTimes(1);
60+
expect(mockEndSpan).toHaveBeenCalledWith(SPAN);
61+
expect(mockCancelSpan).not.toHaveBeenCalled();
62+
});
63+
64+
it('does not end the span on focus before the first layout (cold path)', () => {
65+
renderHook(() => useInboxTabSpanLifecycle());
66+
67+
act(() => {
68+
getFocusCallback()();
69+
});
70+
71+
expect(mockEndSpan).not.toHaveBeenCalled();
72+
});
73+
74+
it('ends the span on re-focus after layout (warm react-freeze path)', () => {
75+
const {result} = renderHook(() => useInboxTabSpanLifecycle());
76+
77+
// First layout completes, then the user leaves and returns: react-freeze keeps the
78+
// layout cached so onLayout will not fire again, and focus must end the span instead.
79+
act(() => {
80+
result.current();
81+
});
82+
mockEndSpan.mockClear();
83+
84+
act(() => {
85+
getFocusCallback()();
86+
});
87+
88+
expect(mockEndSpan).toHaveBeenCalledTimes(1);
89+
expect(mockEndSpan).toHaveBeenCalledWith(SPAN);
90+
});
91+
92+
it('cancels the span on blur when layout never completed (orphan cleanup)', () => {
93+
renderHook(() => useInboxTabSpanLifecycle());
94+
95+
const blurCleanup = getFocusCallback()();
96+
97+
expect(typeof blurCleanup).toBe('function');
98+
act(() => {
99+
blurCleanup?.();
100+
});
101+
102+
expect(mockCancelSpan).toHaveBeenCalledTimes(1);
103+
expect(mockCancelSpan).toHaveBeenCalledWith(SPAN);
104+
});
105+
106+
it('cancels the span on unmount when layout never completed and the active span is unchanged', () => {
107+
const {unmount} = renderHook(() => useInboxTabSpanLifecycle());
108+
109+
unmount();
110+
111+
expect(mockCancelSpan).toHaveBeenCalledTimes(1);
112+
expect(mockCancelSpan).toHaveBeenCalledWith(SPAN);
113+
});
114+
115+
it('does not cancel on unmount when a newer span has started (subsequent tab click)', () => {
116+
const {unmount} = renderHook(() => useInboxTabSpanLifecycle());
117+
118+
// A later tab click started a fresh span before this instance unmounted. The unmount
119+
// cleanup must not cancel that newer span.
120+
currentActiveSpan = newerSpan;
121+
unmount();
122+
123+
expect(mockCancelSpan).not.toHaveBeenCalled();
124+
});
125+
126+
it('does not cancel on unmount after layout has completed', () => {
127+
const {result, unmount} = renderHook(() => useInboxTabSpanLifecycle());
128+
129+
act(() => {
130+
result.current();
131+
});
132+
mockCancelSpan.mockClear();
133+
134+
unmount();
135+
136+
expect(mockCancelSpan).not.toHaveBeenCalled();
137+
});
138+
});

0 commit comments

Comments
 (0)