Skip to content

Commit 6f567df

Browse files
authored
Merge pull request Expensify#83772 from Expensify/vit-removeDeepCompareRef-transactionList
Fix Expensify#83751: prevent overwriting active transaction IDs on referential re-renders
2 parents 3f57c0a + bd042c8 commit 6f567df

2 files changed

Lines changed: 23 additions & 18 deletions

File tree

src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -312,19 +312,24 @@ function MoneyRequestReportTransactionList({
312312
return groupedTransactions.flatMap((group) => group.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)).map((transaction) => transaction.transactionID));
313313
}, [groupedTransactions, sortedTransactions, shouldShowGroupedTransactions]);
314314

315+
// Primitive proxy for visualOrderTransactionIDs used as the effect dependency below.
316+
// Other callers (e.g. TransactionDuplicateReview.onPreviewPressed) can write to the same
317+
// Onyx key with a different ordering. Using the raw array reference would cause the effect
318+
// to re-fire on every referential change and overwrite those IDs. The joined string ensures
319+
// the effect only re-fires when the actual content changes.
320+
const visualOrderTransactionIDsKey = useMemo(() => visualOrderTransactionIDs.join(','), [visualOrderTransactionIDs]);
321+
315322
useEffect(() => {
316323
const focusedRoute = findFocusedRoute(navigationRef.getRootState());
317324
if (focusedRoute?.name !== SCREENS.RIGHT_MODAL.SEARCH_REPORT) {
318325
return;
319326
}
320327
setActiveTransactionIDs(visualOrderTransactionIDs);
321-
}, [visualOrderTransactionIDs]);
322-
323-
useEffect(() => {
324328
return () => {
325329
clearActiveTransactionIDs();
326330
};
327-
}, []);
331+
// eslint-disable-next-line react-hooks/exhaustive-deps -- visualOrderTransactionIDsKey is a primitive proxy for the array to avoid re-firing on referential-only changes
332+
}, [visualOrderTransactionIDsKey]);
328333

329334
const groupSelectionState = useMemo(() => {
330335
const state = new Map<string, {isSelected: boolean; isIndeterminate: boolean; isDisabled: boolean; pendingAction?: PendingAction}>();

tests/unit/MoneyRequestReportTransactionListActiveTransactionIDsTest.tsx

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {findFocusedRoute} from '@react-navigation/native';
22
import {renderHook} from '@testing-library/react-native';
3-
import {useEffect} from 'react';
3+
import {useEffect, useMemo} from 'react';
44
import {clearActiveTransactionIDs, setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation';
55
import {navigationRef} from '@libs/Navigation/Navigation';
66
import SCREENS from '@src/SCREENS';
@@ -29,19 +29,21 @@ jest.mock('@react-navigation/native', () => ({
2929
* to allow isolated testing of the useEffect behavior.
3030
*/
3131
function useActiveTransactionIDsEffect(visualOrderTransactionIDs: string[]) {
32+
const visualOrderTransactionIDsKey = useMemo(() => visualOrderTransactionIDs.join(','), [visualOrderTransactionIDs]);
33+
3234
useEffect(() => {
3335
const focusedRoute = findFocusedRoute(navigationRef.getRootState());
3436
if (focusedRoute?.name !== SCREENS.RIGHT_MODAL.SEARCH_REPORT) {
3537
return;
3638
}
3739
setActiveTransactionIDs(visualOrderTransactionIDs);
38-
}, [visualOrderTransactionIDs]);
39-
40-
useEffect(() => {
4140
return () => {
4241
clearActiveTransactionIDs();
4342
};
44-
}, []);
43+
// eslint-disable-next-line react-hooks/exhaustive-deps -- visualOrderTransactionIDsKey is a primitive proxy for the array
44+
}, [visualOrderTransactionIDsKey]);
45+
46+
return {visualOrderTransactionIDsKey};
4547
}
4648

4749
describe('MoneyRequestReportTransactionList - Active Transaction IDs Effect', () => {
@@ -110,7 +112,7 @@ describe('MoneyRequestReportTransactionList - Active Transaction IDs Effect', ()
110112
expect(mockClearActiveTransactionIDs).toHaveBeenCalledTimes(1);
111113
});
112114

113-
it('should call clearActiveTransactionIDs on unmount even when route was NOT SEARCH_REPORT', () => {
115+
it('should NOT call clearActiveTransactionIDs on unmount when route was NOT SEARCH_REPORT', () => {
114116
// Given the focused route is NOT SEARCH_REPORT
115117
mockFindFocusedRoute.mockReturnValue({name: 'SomeOtherRoute', key: 'test-key'});
116118

@@ -121,8 +123,8 @@ describe('MoneyRequestReportTransactionList - Active Transaction IDs Effect', ()
121123

122124
unmount();
123125

124-
// Then clearActiveTransactionIDs should still be called (cleanup runs on unmount regardless of route)
125-
expect(mockClearActiveTransactionIDs).toHaveBeenCalledTimes(1);
126+
// Then clearActiveTransactionIDs should NOT be called (since the effect returned early, no cleanup was registered)
127+
expect(mockClearActiveTransactionIDs).not.toHaveBeenCalled();
126128
});
127129

128130
it('should update active transaction IDs when the list changes', () => {
@@ -148,7 +150,7 @@ describe('MoneyRequestReportTransactionList - Active Transaction IDs Effect', ()
148150
expect(mockSetActiveTransactionIDs).toHaveBeenLastCalledWith(newTransactionIDs);
149151
});
150152

151-
it('should call setActiveTransactionIDs on reference change without clearing first (idempotent guard is in the action layer)', () => {
153+
it('should NOT re-fire when array reference changes but content is the same', () => {
152154
// Given the focused route is SEARCH_REPORT
153155
mockFindFocusedRoute.mockReturnValue({name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, key: 'test-key'});
154156

@@ -165,11 +167,9 @@ describe('MoneyRequestReportTransactionList - Active Transaction IDs Effect', ()
165167
const sameContentNewArray = ['trans1', 'trans2'];
166168
rerender({ids: sameContentNewArray});
167169

168-
// Then the effect fires (new reference), but setActiveTransactionIDs internally skips the Onyx write.
169-
// Crucially, clearActiveTransactionIDs is NOT called on re-render (only on unmount),
170-
// preventing the null→same-IDs flash.
171-
expect(mockSetActiveTransactionIDs).toHaveBeenCalledTimes(2);
172-
expect(mockSetActiveTransactionIDs).toHaveBeenLastCalledWith(sameContentNewArray);
170+
// Then the effect should NOT re-fire because the join(',') key hasn't changed.
171+
// This prevents overwriting IDs set by other callers (e.g. TransactionDuplicateReview.onPreviewPressed).
172+
expect(mockSetActiveTransactionIDs).toHaveBeenCalledTimes(1);
173173
expect(mockClearActiveTransactionIDs).not.toHaveBeenCalled();
174174
});
175175

0 commit comments

Comments
 (0)