From 7517a11e6781d45f43c2e3cacf907b17a448635c Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 19 Jun 2026 14:09:34 +0200 Subject: [PATCH 1/2] perf: scope VISIBLE_REPORT_ACTIONS subscription to a single report --- src/hooks/useReportActionsVisibility.ts | 7 ++++++- src/selectors/ReportAction.ts | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/hooks/useReportActionsVisibility.ts b/src/hooks/useReportActionsVisibility.ts index 7f94245a4dc6..90b8dfeb9617 100644 --- a/src/hooks/useReportActionsVisibility.ts +++ b/src/hooks/useReportActionsVisibility.ts @@ -1,9 +1,11 @@ +import {reportVisibleActionsSelector} from '@selectors/ReportAction'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import {isCreatedAction, isDeletedParentAction, isIOUActionMatchingTransactionList, isReportActionVisible} from '@libs/ReportActionsUtils'; import {isConciergeChatReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction} from '@src/types/onyx'; +import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; import useConciergeSidePanelReportActions from './useConciergeSidePanelReportActions'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useIsInSidePanel from './useIsInSidePanel'; @@ -58,7 +60,10 @@ function useReportActionsVisibility({ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); + const [reportVisibleActions] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { + selector: reportVisibleActionsSelector(reportID), + }); + const visibleReportActionsData: VisibleReportActionsDerivedValue | undefined = reportID && reportVisibleActions ? {[reportID]: reportVisibleActions} : undefined; const isInSidePanel = useIsInSidePanel(); const isConciergeSidePanel = isInSidePanel && isConciergeChatReport(report, conciergeReportID); diff --git a/src/selectors/ReportAction.ts b/src/selectors/ReportAction.ts index c33717e1f331..2f89fb50fbcf 100644 --- a/src/selectors/ReportAction.ts +++ b/src/selectors/ReportAction.ts @@ -3,6 +3,12 @@ import type {OnyxEntry} from 'react-native-onyx'; import {filterOutDeprecatedReportActions, getLinkedTransactionID, getSortedReportActions, isActionOfType} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; import type {ReportAction, ReportActions} from '@src/types/onyx'; +import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; + +/** + * Module-level selector factory that scopes the VISIBLE_REPORT_ACTIONS derived value to a single report + */ +const reportVisibleActionsSelector = (reportID: string | undefined) => (data: VisibleReportActionsDerivedValue | undefined) => (reportID ? data?.[reportID] : undefined); function getParentReportActionSelector(parentReportActions: OnyxEntry, parentReportActionID?: string): OnyxEntry { if (!parentReportActions || !parentReportActionID) { @@ -83,4 +89,4 @@ function getReceiptScanFailedIOUActionDataSelector( }; } -export {getParentReportActionSelector, getLastClosedReportAction, getReportActionByIDSelector, getReceiptScanFailedIOUActionDataSelector}; +export {getParentReportActionSelector, getLastClosedReportAction, getReportActionByIDSelector, getReceiptScanFailedIOUActionDataSelector, reportVisibleActionsSelector}; From 7018b8c40d2a28a76a4179d4866680daea38b3f6 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 19 Jun 2026 14:34:09 +0200 Subject: [PATCH 2/2] add useReportActionsVisibility test --- tests/unit/useReportActionsVisibilityTest.tsx | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 tests/unit/useReportActionsVisibilityTest.tsx diff --git a/tests/unit/useReportActionsVisibilityTest.tsx b/tests/unit/useReportActionsVisibilityTest.tsx new file mode 100644 index 000000000000..caa749a3099e --- /dev/null +++ b/tests/unit/useReportActionsVisibilityTest.tsx @@ -0,0 +1,124 @@ +import {act, renderHook} from '@testing-library/react-native'; +import type {ReactNode} from 'react'; +import Onyx from 'react-native-onyx'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import useReportActionsVisibility from '@hooks/useReportActionsVisibility'; +import initOnyxDerivedValues from '@userActions/OnyxDerived'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportActions} from '@src/types/onyx'; +import {getFakeReportAction} from '../utils/ReportTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +/** + * Guards that `useReportActionsVisibility` subscribes to VISIBLE_REPORT_ACTIONS scoped to its own + * report via `reportVisibleActionsSelector`. The test renders the real hook and counts how often a + * consumer re-renders: with the per-report selector, an unrelated report's activity must not + * re-render this report's consumer. + */ + +const REPORT_A = 'reportA'; +const REPORT_B = 'reportB'; + +/** Build a deterministic, visible ADD_COMMENT action keyed by its reportActionID. */ +function buildVisibleComment(reportActionID: string) { + return getFakeReportAction(1, {reportActionID, actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); +} + +function buildActions(...reportActionIDs: string[]): ReportActions { + return Object.fromEntries(reportActionIDs.map((id) => [id, buildVisibleComment(id)])); +} + +function setReportActions(reportID: string, actions: ReportActions) { + return Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, actions); +} + +type HookProps = Parameters[0]; + +function buildHookProps(reportID: string, actions: ReportActions): HookProps { + const actionsArray = Object.values(actions); + return { + reportID, + reportActions: actionsArray, + allReportActions: actionsArray, + canPerformWriteAction: true, + hasOlderActions: false, + loadOlderChats: jest.fn(), + }; +} + +const wrapper = ({children}: {children: ReactNode}) => {children}; + +describe('useReportActionsVisibility scopes VISIBLE_REPORT_ACTIONS per report', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + initOnyxDerivedValues(); + }); + + beforeEach(async () => { + await Onyx.clear(); + TestHelper.signInWithTestUser(1, 'test@test.com'); + await waitForBatchedUpdates(); + }); + + it('does not re-render the open report (A) when an unrelated report (B) actions change', async () => { + // Given reports A and B both have visible actions in the derived value, and the hook is mounted for A + await setReportActions(REPORT_A, buildActions('a1', 'a2')); + await setReportActions(REPORT_B, buildActions('b1')); + await waitForBatchedUpdates(); + + let renders = 0; + const {unmount} = renderHook( + () => { + renders++; + return useReportActionsVisibility(buildHookProps(REPORT_A, buildActions('a1', 'a2'))); + }, + {wrapper}, + ); + await waitForBatchedUpdates(); + + const rendersBefore = renders; + + // When a new action is added to the unrelated report B + await act(async () => { + await setReportActions(REPORT_B, buildActions('b2')); + await waitForBatchedUpdates(); + }); + + // Then the hook for report A does not re-render. + // (With a whole-collection subscription, report B's write would re-render A here.) + expect(renders).toBe(rendersBefore); + + unmount(); + }); + + it('re-renders the open report (A) when its own actions change (guards against over-scoping)', async () => { + // Given the hook is mounted for report A + await setReportActions(REPORT_A, buildActions('a1')); + await waitForBatchedUpdates(); + + let renders = 0; + const {unmount} = renderHook( + () => { + renders++; + return useReportActionsVisibility(buildHookProps(REPORT_A, buildActions('a1'))); + }, + {wrapper}, + ); + await waitForBatchedUpdates(); + + const rendersBefore = renders; + + // When a new action is added to report A itself + await act(async () => { + await setReportActions(REPORT_A, buildActions('a2')); + await waitForBatchedUpdates(); + }); + + // Then the hook reacted to its own report's visibility change + expect(renders).toBeGreaterThan(rendersBefore); + + unmount(); + }); +});