diff --git a/src/hooks/useInboxTabSpanLifecycle.ts b/src/hooks/useInboxTabSpanLifecycle.ts index 470f34bddcaa..7acf921e4899 100644 --- a/src/hooks/useInboxTabSpanLifecycle.ts +++ b/src/hooks/useInboxTabSpanLifecycle.ts @@ -9,8 +9,10 @@ import {useEffect, useRef} from 'react'; * Manages the ManualNavigateToInboxTab span lifecycle for the inbox sidebar. * * Three signals are handled: - * - onLayout fires on first mount: ends the span (normal path). - * - useFocusEffect fires on re-focus when react-freeze has cached the layout: ends the span (warm path). + * - onLayout ends the span: cold on the first layout, warm on any later one. It can fire more than once on the + * same instance, so it cannot assume a fresh mount. + * - useFocusEffect ends the span as warm on re-focus. On native this is the only signal, because react-freeze + * keeps the cached layout and onLayout does not fire again. * The blur cleanup cancels any orphaned span when the user navigates away before layout completes. * - useEffect unmount cleanup cancels the span only if layout never completed AND the active span * is the same one that was present when this instance mounted (avoids canceling a span started @@ -22,9 +24,13 @@ function useInboxTabSpanLifecycle(): () => void { const hasHadFirstLayout = useRef(false); const spanOnMount = useRef(getSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB)); + // onLayout can fire more than once on the same instance, so the first layout is the cold one and any later + // layout is a warm re-visit. Both end paths read this ref, so the reported value does not depend on which + // of them fires first. const onLayout = () => { + const isRepeatLayout = hasHadFirstLayout.current; hasHadFirstLayout.current = true; - endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: false}); + endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: isRepeatLayout}); spanOnMount.current = undefined; }; diff --git a/tests/unit/hooks/useInboxTabSpanLifecycle.test.ts b/tests/unit/hooks/useInboxTabSpanLifecycle.test.ts index 44a6407e5992..810e0df7c6aa 100644 --- a/tests/unit/hooks/useInboxTabSpanLifecycle.test.ts +++ b/tests/unit/hooks/useInboxTabSpanLifecycle.test.ts @@ -125,6 +125,42 @@ describe('useInboxTabSpanLifecycle', () => { expect(mockCancelSpan).not.toHaveBeenCalled(); }); + it('reports a repeat layout as warm', () => { + const {result} = renderHook(() => useInboxTabSpanLifecycle()); + + act(() => { + result.current(); + }); + mockEndSpanWithAttributes.mockClear(); + + act(() => { + result.current(); + }); + + expect(mockEndSpanWithAttributes).toHaveBeenCalledTimes(1); + expect(mockEndSpanWithAttributes).toHaveBeenCalledWith(SPAN, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true}); + }); + + it('reports the same value from either end path', () => { + const {result} = renderHook(() => useInboxTabSpanLifecycle()); + + act(() => { + result.current(); + }); + mockEndSpanWithAttributes.mockClear(); + + act(() => { + result.current(); + }); + act(() => { + getFocusCallback()(); + }); + + expect(mockEndSpanWithAttributes).toHaveBeenCalledTimes(2); + expect(mockEndSpanWithAttributes).toHaveBeenNthCalledWith(1, SPAN, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true}); + expect(mockEndSpanWithAttributes).toHaveBeenNthCalledWith(2, SPAN, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true}); + }); + it('does not cancel on unmount after layout has completed', () => { const {result, unmount} = renderHook(() => useInboxTabSpanLifecycle());