diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index fdb173f69117f..c99dd892e1b0a 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -11,6 +11,7 @@ import {getEmptyObject} from '@src/types/utils/EmptyObject'; import mapOnyxCollectionItems from '@src/utils/mapOnyxCollectionItems'; import useCurrentReportID from './useCurrentReportID'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useDeepCompareRef from './useDeepCompareRef'; import useDiffPrevious from './useDiffPrevious'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; @@ -176,12 +177,14 @@ function SidebarOrderedReportsContextProvider({ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [getUpdatedReports, chatReports, derivedCurrentReportID, priorityMode, betas, policies, transactionViolations, reportNameValuePairs, reportAttributes, reportsDraftsUpdates]); + const deepComparedReportsToDisplayInLHN = useDeepCompareRef(reportsToDisplayInLHN); + useEffect(() => { setCurrentReportsToDisplay(reportsToDisplayInLHN); }, [reportsToDisplayInLHN]); const getOrderedReportIDs = useCallback( - () => SidebarUtils.sortReportsToDisplayInLHN(reportsToDisplayInLHN, priorityMode, localeCompare, reportNameValuePairs, reportAttributes, drafts), + () => SidebarUtils.sortReportsToDisplayInLHN(deepComparedReportsToDisplayInLHN ?? {}, priorityMode, localeCompare, reportNameValuePairs, reportAttributes, drafts), // Rule disabled intentionally - reports should be sorted only when the reportsToDisplayInLHN changes // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps [reportsToDisplayInLHN, localeCompare], diff --git a/tests/unit/useSidebarOrderedReportsTest.tsx b/tests/unit/useSidebarOrderedReportsTest.tsx new file mode 100644 index 0000000000000..9b0aff7848211 --- /dev/null +++ b/tests/unit/useSidebarOrderedReportsTest.tsx @@ -0,0 +1,281 @@ +import {renderHook} from '@testing-library/react-native'; +import React from 'react'; +import type {OnyxMultiSetInput} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; +import {SidebarOrderedReportsContextProvider, useSidebarOrderedReports} from '@hooks/useSidebarOrderedReports'; +import SidebarUtils from '@libs/SidebarUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +// Mock dependencies +jest.mock('@libs/SidebarUtils', () => ({ + sortReportsToDisplayInLHN: jest.fn(), + getReportsToDisplayInLHN: jest.fn(), + updateReportsToDisplayInLHN: jest.fn(), +})); +jest.mock('@libs/Navigation/Navigation', () => ({ + getTopmostReportId: jest.fn(), +})); +jest.mock('@libs/ReportUtils', () => ({ + parseReportRouteParams: jest.fn(() => ({reportID: undefined})), + getReportIDFromLink: jest.fn(() => ''), +})); + +const mockSidebarUtils = SidebarUtils as jest.Mocked; + +describe('useSidebarOrderedReports', () => { + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS}); + // Set up basic session data + await Onyx.set(ONYXKEYS.SESSION, { + accountID: 12345, + email: 'test@example.com', + authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS, + }); + return waitForBatchedUpdates(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + Onyx.clear(); + + // Set up basic session data for each test + await Onyx.set(ONYXKEYS.SESSION, { + accountID: 12345, + email: 'test@example.com', + authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS, + }); + + // Set up required Onyx data that the hook depends on + await Onyx.multiSet({ + [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, + [ONYXKEYS.COLLECTION.REPORT]: {}, + [ONYXKEYS.COLLECTION.POLICY]: {}, + [ONYXKEYS.COLLECTION.TRANSACTION]: {}, + [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: {}, + [ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]: {}, + [ONYXKEYS.NVP_DRAFT_REPORT_COMMENTS]: {}, + [ONYXKEYS.BETAS]: [], + [ONYXKEYS.DERIVED.REPORT_ATTRIBUTES]: {reports: {}}, + } as unknown as OnyxMultiSetInput); + + // Default mock implementations + mockSidebarUtils.getReportsToDisplayInLHN.mockImplementation(() => ({})); + mockSidebarUtils.updateReportsToDisplayInLHN.mockImplementation((prev) => prev); + mockSidebarUtils.sortReportsToDisplayInLHN.mockReturnValue([]); + + return waitForBatchedUpdates(); + }); + + afterAll(async () => { + Onyx.clear(); + await waitForBatchedUpdates(); + }); + + const createMockReports = (reports: Record>) => { + const mockReports: Record = {}; + Object.entries(reports).forEach(([key, report]) => { + const reportId = key.replace('report', ''); + mockReports[reportId] = { + reportID: reportId, + reportName: `Report ${reportId}`, + lastVisibleActionCreated: '2024-01-01 10:00:00', + type: CONST.REPORT.TYPE.CHAT, + ...report, + } as Report; + }); + return mockReports; + }; + + let currentReportIDForTestsValue: string | undefined; + + function TestWrapper({children}: {children: React.ReactNode}) { + return ( + + + {children} + + + ); + } + + it('should prevent unnecessary re-renders when reports have same content but different references', () => { + // Given reports with same content but different object references + const reportsContent = { + report1: {reportName: 'Chat 1', lastVisibleActionCreated: '2024-01-01 10:00:00'}, + report2: {reportName: 'Chat 2', lastVisibleActionCreated: '2024-01-01 11:00:00'}, + }; + + // When the initial reports are set + const initialReports = createMockReports(reportsContent); + mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(initialReports); + mockSidebarUtils.updateReportsToDisplayInLHN.mockImplementation((prev) => ({...prev})); + currentReportIDForTestsValue = '1'; + + // When the hook is rendered + const {rerender} = renderHook(() => useSidebarOrderedReports(), { + wrapper: TestWrapper, + }); + + // Then the mock calls are cleared + mockSidebarUtils.sortReportsToDisplayInLHN.mockClear(); + + // When the reports are updated + const newReportsWithSameContent = createMockReports(reportsContent); + mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(newReportsWithSameContent); + + rerender({}); + + // Then sortReportsToDisplayInLHN should not be called again since deep comparison shows no change + expect(mockSidebarUtils.sortReportsToDisplayInLHN).not.toHaveBeenCalled(); + }); + + it('should trigger re-render when reports content actually changes', async () => { + // Given the initial reports are set + const initialReports = createMockReports({ + report1: {reportName: 'Chat 1'}, + report2: {reportName: 'Chat 2'}, + }); + + // When the reports are updated + const updatedReports = createMockReports({ + report1: {reportName: 'Chat 1 Updated'}, // Content changed + report2: {reportName: 'Chat 2'}, + report3: {reportName: 'Chat 3'}, // New report added + }); + + // Then the initial reports are set + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}1`]: initialReports['1'], + [`${ONYXKEYS.COLLECTION.REPORT}2`]: initialReports['2'], + } as unknown as OnyxMultiSetInput); + + // When the mock is updated + mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(initialReports); + + // When the hook is rendered + const {rerender} = renderHook(() => useSidebarOrderedReports(), { + wrapper: TestWrapper, + }); + + await waitForBatchedUpdates(); + + // Then the mock calls are cleared + mockSidebarUtils.sortReportsToDisplayInLHN.mockClear(); + + // When the mock is updated + mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(updatedReports); + + // When the priority mode is changed + await Onyx.set(ONYXKEYS.NVP_PRIORITY_MODE, CONST.PRIORITY_MODE.GSD); + + rerender({}); + + await waitForBatchedUpdates(); + + // Then sortReportsToDisplayInLHN should be called with the updated reports + expect(mockSidebarUtils.sortReportsToDisplayInLHN).toHaveBeenCalledWith( + updatedReports, + expect.any(String), // priorityMode + expect.any(Function), // localeCompare + expect.any(Object), // reportNameValuePairs + expect.any(Object), // reportAttributes + expect.any(Object), // drafts + ); + }); + + it('should handle empty reports correctly with deep comparison', async () => { + // Given the initial reports are set + mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue({}); + + // When the hook is rendered + const {rerender} = renderHook(() => useSidebarOrderedReports(), { + wrapper: TestWrapper, + }); + + await waitForBatchedUpdates(); + + // Then the mock calls are cleared + mockSidebarUtils.sortReportsToDisplayInLHN.mockClear(); + + // When the mock is updated + mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue({}); + + rerender({}); + + await waitForBatchedUpdates(); + + // Then sortReportsToDisplayInLHN should not be called again since reports are empty + expect(mockSidebarUtils.sortReportsToDisplayInLHN).not.toHaveBeenCalled(); + }); + + it('should maintain referential stability across multiple renders with same content', () => { + // Given the initial reports are set + const reportsContent = { + report1: {reportName: 'Stable Chat'}, + }; + + // When the initial reports are set + const initialReports = createMockReports(reportsContent); + mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(initialReports); + mockSidebarUtils.sortReportsToDisplayInLHN.mockReturnValue(['1']); + currentReportIDForTestsValue = '1'; + + const {rerender} = renderHook(() => useSidebarOrderedReports(), { + wrapper: TestWrapper, + }); + + // When the mock is updated + const newReportsWithSameContent = createMockReports(reportsContent); + mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(newReportsWithSameContent); + + rerender({}); + currentReportIDForTestsValue = '2'; + + // When the mock is updated + const thirdReportsWithSameContent = createMockReports(reportsContent); + mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(thirdReportsWithSameContent); + + rerender({}); + currentReportIDForTestsValue = '3'; + + // Then sortReportsToDisplayInLHN should be called only once (initial render) + expect(mockSidebarUtils.sortReportsToDisplayInLHN).toHaveBeenCalledTimes(1); + }); + + it('should handle priority mode changes correctly with deep comparison', async () => { + // Given the initial reports are set + const reports = createMockReports({ + report1: {reportName: 'Chat A'}, + report2: {reportName: 'Chat B'}, + }); + + mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue(reports); + currentReportIDForTestsValue = '1'; + + // When the hook is rendered + const {rerender} = renderHook(() => useSidebarOrderedReports(), { + wrapper: TestWrapper, + }); + + await waitForBatchedUpdates(); + + // Then the mock calls are cleared + mockSidebarUtils.sortReportsToDisplayInLHN.mockClear(); + currentReportIDForTestsValue = '2'; + + // When the priority mode is changed + await Onyx.set(ONYXKEYS.NVP_PRIORITY_MODE, CONST.PRIORITY_MODE.GSD); + + rerender({}); + + await waitForBatchedUpdates(); + + // Then sortReportsToDisplayInLHN should be called when priority mode changes + expect(mockSidebarUtils.sortReportsToDisplayInLHN).toHaveBeenCalled(); + }); +});