Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@

const [session] = useOnyx(ONYXKEYS.SESSION);
const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getNonEmptyStringOnyxID(reportID)}`);
const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`);
const shouldShowHarvestCreatedAction = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID);
const [offlineModalVisible, setOfflineModalVisible] = useState(false);
const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false);
Expand Down Expand Up @@ -370,13 +371,14 @@
if (!isFocused) {
return;
}

if (isUnread(report, transactionThreadReport, isReportArchived) || (lastAction && isCurrentActionUnread(report, lastAction, visibleReportActions))) {
// On desktop, when the notification center is displayed, isVisible will return false.
// Currently, there's no programmatic way to dismiss the notification center panel.
// To handle this, we use the 'referrer' parameter to check if the current navigation is triggered from a notification.
const isFromNotification = route?.params?.referrer === CONST.REFERRER.NOTIFICATION;
if ((isVisible || isFromNotification) && scrollingVerticalBottomOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) {
readNewestAction(report.reportID);
readNewestAction(report.reportID, !!reportMetadata?.hasOnceLoadedReportActions);
if (isFromNotification) {
Navigation.setParams({referrer: undefined});
}
Expand All @@ -385,7 +387,7 @@
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [report.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, report.reportID, isVisible]);
}, [report.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, report.reportID, isVisible, reportMetadata?.hasOnceLoadedReportActions]);

useEffect(() => {
if (!isVisible || !isFocused) {
Expand All @@ -409,15 +411,15 @@
return;
}

readNewestAction(report.reportID);
readNewestAction(report.reportID, !!reportMetadata?.hasOnceLoadedReportActions);
userActiveSince.current = DateUtils.getDBTime();

// This effect logic to `mark as read` will only run when the report focused has new messages and the App visibility
// is changed to visible(meaning user switched to app/web, while user was previously using different tab or application).
// We will mark the report as read in the above case which marks the LHN report item as read while showing the new message
// marker for the chat messages received while the user wasn't focused on the report or on another browser tab for web.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFocused, isVisible]);
}, [isFocused, isVisible, reportMetadata?.hasOnceLoadedReportActions]);

/**
* The index of the earliest message that was received while offline
Expand Down Expand Up @@ -488,6 +490,7 @@
// We additionally track the top offset to be able to scroll to the new transaction when it's added
scrollingVerticalTopOffset.current = contentOffset.y;
},
hasOnceLoadedReportActions: !!reportMetadata?.hasOnceLoadedReportActions,
});

useEffect(() => {
Expand Down Expand Up @@ -673,8 +676,8 @@

reportScrollManager.scrollToEnd();
readActionSkipped.current = false;
readNewestAction(report.reportID);
}, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, report.reportID]);
readNewestAction(report.reportID, !!reportMetadata?.hasOnceLoadedReportActions);
}, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, report.reportID, reportMetadata?.hasOnceLoadedReportActions]);

Check warning on line 680 in src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

React Hook useCallback has a missing dependency: 'introSelected'. Either include it or remove the dependency array

Check warning on line 680 in src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

React Hook useCallback has a missing dependency: 'introSelected'. Either include it or remove the dependency array

const scrollToNewTransaction = useCallback(
(pageY: number) => {
Expand Down
9 changes: 8 additions & 1 deletion src/libs/actions/Report/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@
/** @deprecated This value is deprecated and will be removed soon after migration. Use the email from useCurrentUserPersonalDetails hook instead. */
let deprecatedCurrentUserLogin: string | undefined;

Onyx.connect({

Check warning on line 317 in src/libs/actions/Report/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.SESSION,
callback: (value) => {
// When signed out, val is undefined
Expand All @@ -328,7 +328,7 @@
},
});

Onyx.connect({

Check warning on line 331 in src/libs/actions/Report/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.CONCIERGE_REPORT_ID,
callback: (value) => (conciergeReportIDOnyxConnect = value),
});
Expand All @@ -336,7 +336,7 @@
// map of reportID to all reportActions for that report
const allReportActions: OnyxCollection<ReportActions> = {};

Onyx.connect({

Check warning on line 339 in src/libs/actions/Report/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
callback: (actions, key) => {
if (!key || !actions) {
Expand All @@ -348,7 +348,7 @@
});

let allReports: OnyxCollection<Report>;
Onyx.connect({

Check warning on line 351 in src/libs/actions/Report/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -357,7 +357,7 @@
});

let allPersonalDetails: OnyxEntry<PersonalDetailsList> = {};
Onyx.connect({

Check warning on line 360 in src/libs/actions/Report/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (value) => {
allPersonalDetails = value ?? {};
Expand All @@ -372,7 +372,7 @@
});

let onboarding: OnyxEntry<Onboarding>;
Onyx.connect({

Check warning on line 375 in src/libs/actions/Report/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.NVP_ONBOARDING,
callback: (val) => {
if (Array.isArray(val)) {
Expand All @@ -383,7 +383,7 @@
});

let deprecatedIntroSelected: OnyxEntry<IntroSelected> = {};
Onyx.connect({

Check warning on line 386 in src/libs/actions/Report/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.NVP_INTRO_SELECTED,
callback: (val) => (deprecatedIntroSelected = val),
});
Expand Down Expand Up @@ -1916,14 +1916,21 @@
}

/** Marks the new report actions as read
* @param hasOnceLoadedReportActions Whether the report actions have been loaded at least once.
* If false, the API call will be skipped to avoid 401 errors from reading reports not yet shared with the user.
* @param shouldResetUnreadMarker Indicates whether the unread indicator should be reset.
* Currently, the unread indicator needs to be reset only when users mark a report as read.
*/
function readNewestAction(reportID: string | undefined, shouldResetUnreadMarker = false) {
function readNewestAction(reportID: string | undefined, hasOnceLoadedReportActions: boolean, shouldResetUnreadMarker = false) {
if (!reportID) {
return;
}

// Do not try to mark the report as read if the report has not been loaded and shared with the user
if (!hasOnceLoadedReportActions) {
return;
}
Comment on lines +1930 to +1932

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have not prevented readNewestAction if report actions for the report exist. Solved here


const lastReadTime = NetworkConnection.getDBTimeWithSkew();

const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT>> = [
Expand Down
4 changes: 2 additions & 2 deletions src/pages/inbox/ReportScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -925,8 +925,8 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr
return;
}
// After creating the task report then navigating to task detail we don't have any report actions and the last read time is empty so We need to update the initial last read time when opening the task report detail.
readNewestAction(report?.reportID);
}, [report]);
readNewestAction(report?.reportID, !!reportMetadata?.hasOnceLoadedReportActions);
}, [report, reportMetadata?.hasOnceLoadedReportActions]);

// Reset the ref when navigating to a different report
useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ const ContextMenuActions: ContextMenuAction[] = [
successIcon: 'Checkmark',
shouldShow: ({type, isUnreadChat}) => type === CONST.CONTEXT_MENU_TYPES.REPORT && isUnreadChat,
onPress: (closePopover, {reportID}) => {
readNewestAction(reportID, true);
readNewestAction(reportID, true, true);
if (closePopover) {
hideContextMenu(true, ReportActionComposeFocusManager.focus);
}
Expand Down
16 changes: 6 additions & 10 deletions src/pages/inbox/report/ReportActionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ function ReportActionsList({
setShouldScrollToEndAfterLayout(false);
}
},
hasOnceLoadedReportActions: !!reportMetadata?.hasOnceLoadedReportActions,
});

useEffect(() => {
Expand Down Expand Up @@ -402,18 +403,13 @@ function ReportActionsList({
return;
}

// Do not try to mark the report as read if the report has not been loaded and shared with the user
if (!reportMetadata?.hasOnceLoadedReportActions) {
return;
}

if (isUnread(report, transactionThreadReport, isReportArchived) || (lastAction && isCurrentActionUnread(report, lastAction, sortedVisibleReportActions))) {
// On desktop, when the notification center is displayed, isVisible will return false.
// Currently, there's no programmatic way to dismiss the notification center panel.
// To handle this, we use the 'referrer' parameter to check if the current navigation is triggered from a notification.
const isFromNotification = route?.params?.referrer === CONST.REFERRER.NOTIFICATION;
if ((isVisible || isFromNotification) && scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) {
readNewestAction(report.reportID);
readNewestAction(report.reportID, !!reportMetadata?.hasOnceLoadedReportActions);
if (isFromNotification) {
Navigation.setParams({referrer: undefined});
}
Expand Down Expand Up @@ -455,7 +451,7 @@ function ReportActionsList({
return;
}

readNewestAction(report.reportID);
readNewestAction(report.reportID, !!reportMetadata?.hasOnceLoadedReportActions);
userActiveSince.current = DateUtils.getDBTime();
return true;

Expand All @@ -464,7 +460,7 @@ function ReportActionsList({
// We will mark the report as read in the above case which marks the LHN report item as read while showing the new message
// marker for the chat messages received while the user wasn't focused on the report or on another browser tab for web.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFocused, isVisible]);
}, [isFocused, isVisible, reportMetadata?.hasOnceLoadedReportActions]);

const prevHandleReportChangeMarkAsRead = useRef<() => void>(null);
const prevHandleAppVisibilityMarkAsRead = useRef<() => void>(null);
Expand Down Expand Up @@ -631,8 +627,8 @@ function ReportActionsList({
}
reportScrollManager.scrollToBottom();
readActionSkipped.current = false;
readNewestAction(report.reportID);
}, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, report.reportID, backTo, introSelected]);
readNewestAction(report.reportID, !!reportMetadata?.hasOnceLoadedReportActions);
}, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, report.reportID, backTo, introSelected, reportMetadata?.hasOnceLoadedReportActions]);

/**
* Calculates the ideal number of report actions to render in the first render, based on the screen height and on
Expand Down
13 changes: 11 additions & 2 deletions src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ type Args = {

/** Callback to call on every scroll event */
onTrackScrolling: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;

/** Whether the report actions have been loaded at least once */
hasOnceLoadedReportActions: boolean;
};

export default function useReportUnreadMessageScrollTracking({
Expand All @@ -32,14 +35,16 @@ export default function useReportUnreadMessageScrollTracking({
onTrackScrolling,
unreadMarkerReportActionIndex,
isInverted,
hasOnceLoadedReportActions,
}: Args) {
const [isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible] = useState(false);
const isFocused = useIsFocused();
const ref = useRef<{previousViewableItems: ViewToken[]; reportID: string; unreadMarkerReportActionIndex: number; isFocused: boolean}>({
const ref = useRef<{previousViewableItems: ViewToken[]; reportID: string; unreadMarkerReportActionIndex: number; isFocused: boolean; hasOnceLoadedReportActions: boolean}>({
reportID,
unreadMarkerReportActionIndex,
previousViewableItems: [],
isFocused: true,
hasOnceLoadedReportActions,
});
Comment on lines +42 to 48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Initialize ref with current hasOnceLoadedReportActions

When hasOnceLoadedReportActions is already true on first render, the ref is still initialized to false, so the initial onViewableItemsChanged call can skip readNewestAction even though the report is fully loaded. If the viewable items don’t change afterward (e.g., user opens the report and doesn’t scroll), the skipped read may never be retried, leaving the report unread until another scroll occurs. Initializing the ref with the current prop value avoids this race on first render.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment is technically correct, @MelvinBot could you please check?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The review comment identifies a real inconsistency in the ref initialization pattern, but it is not an actual bug in practice. Here's why:

The concern

The ref is initialized with hasOnceLoadedReportActions: false (hardcoded) rather than using the prop value. If hasOnceLoadedReportActions is already true on first render, the ref would be stale until the useEffect runs.

Why it doesn't cause a bug

The only place ref.current.hasOnceLoadedReportActions is read is in onViewableItemsChanged at line 123:

if (unreadActionVisible && readActionSkippedRef.current && ref.current.hasOnceLoadedReportActions) {

For the stale false to cause a missed readNewestAction call, both conditions would need to be true simultaneously:

  1. readActionSkippedRef.current === true
  2. ref.current.hasOnceLoadedReportActions === false (stale)

But readActionSkippedRef.current can only be set to true inside handleReportChangeMarkAsRead / handleAppVisibilityMarkAsRead in ReportActionsList.tsx and MoneyRequestReportActionsList.tsx — and all of those code paths are already guarded by:

if (!reportMetadata?.hasOnceLoadedReportActions) {
    return;
}

So by the time readActionSkippedRef.current is ever true, hasOnceLoadedReportActions was already true in the parent component, and the sync useEffect will have already updated the ref (since the parent's useEffect that sets readActionSkipped = true runs after the hook's useEffect that syncs the ref).

Additionally, onViewableItemsChanged is fired by FlatList asynchronously after layout — not during the synchronous render phase — so effects will always have executed before it fires.

Recommendation

That said, initializing the ref with the prop value (hasOnceLoadedReportActions instead of false) is a harmless defensive improvement that would make the code more consistent — reportID and unreadMarkerReportActionIndex are already initialized from their prop values, while isFocused and hasOnceLoadedReportActions use hardcoded defaults. Making this change would improve code clarity even though it doesn't fix an actual bug.

// We want to save the updated value on ref to use it in onViewableItemsChanged
// because FlatList requires the callback to be stable and we cannot add a dependency on the useCallback.
Expand All @@ -52,6 +57,10 @@ export default function useReportUnreadMessageScrollTracking({
ref.current.isFocused = isFocused;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PERF-6 (docs)

The hasOnceLoadedReportActions value is being synced from a prop to a ref using useEffect. This creates unnecessary complexity and potential stale value issues.

Suggested fix: Eliminate the useEffect and the ref field. Instead, pass hasOnceLoadedReportActions as a parameter directly in the onViewableItemsChanged callback:

// In the onViewableItemsChanged callback:
if (unreadActionVisible && readActionSkippedRef.current && hasOnceLoadedReportActions) {
    readActionSkippedRef.current = false;
    readNewestAction(ref.current.reportID);
}

Since onViewableItemsChanged is already a stable callback (as indicated by the eslint-disable comment), and the callback is manually triggered in the useEffect when dependencies change, the latest hasOnceLoadedReportActions value will always be available when the callback executes.


Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review! However, I believe the current implementation is correct and follows the established pattern in this file.

The onViewableItemsChanged callback is intentionally memoized with an empty dependency array (line 132: }, []) to keep it stable for FlatList, as required for optimal performance. This is explicitly documented in the comments at lines 49-50 and 129-131:

// We want to save the updated value on ref to use it in onViewableItemsChanged
// because FlatList requires the callback to be stable and we cannot add a dependency on the useCallback.

Because the callback never re-creates, using hasOnceLoadedReportActions directly as a closure variable would capture the initial value and never update when the prop changes. The ref pattern is the correct approach to access current values from within a stable callback.

This same pattern is already used in this file for:

  • reportID (lines 51-54)
  • isFocused (lines 56-58)
  • unreadMarkerReportActionIndex (line 137)

The new useEffect for hasOnceLoadedReportActions (lines 60-62) follows exactly the same pattern, ensuring the callback always has access to the latest value via ref.current.hasOnceLoadedReportActions at line 123.

}, [isFocused]);

useEffect(() => {
ref.current.hasOnceLoadedReportActions = hasOnceLoadedReportActions;
}, [hasOnceLoadedReportActions]);

/**
* On every scroll event we want to:
* Show/hide the latest message pill when user is scrolling back/forth in the history of messages.
Expand Down Expand Up @@ -113,7 +122,7 @@ export default function useReportUnreadMessageScrollTracking({
if (unreadActionVisible && readActionSkippedRef.current) {
// eslint-disable-next-line no-param-reassign
readActionSkippedRef.current = false;
readNewestAction(ref.current.reportID);
readNewestAction(ref.current.reportID, ref.current.hasOnceLoadedReportActions);
}

// FlatList requires a stable onViewableItemsChanged callback for optimal performance.
Expand Down
2 changes: 1 addition & 1 deletion tests/actions/ReportTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ describe('actions/Report', () => {
// When the user visits the report
currentTime = DateUtils.getDBTime();
Report.openReport(REPORT_ID, TEST_INTRO_SELECTED);
Report.readNewestAction(REPORT_ID);
Report.readNewestAction(REPORT_ID, true);
waitForBatchedUpdates();
return waitForBatchedUpdates();
})
Expand Down
2 changes: 1 addition & 1 deletion tests/ui/UnreadIndicatorsTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -672,7 +672,7 @@ describe('Unread Indicators', () => {
// Given a read report
await signInAndGetAppWithUnreadChat();

readNewestAction(REPORT_ID, true);
readNewestAction(REPORT_ID, true, true);

await waitForBatchedUpdates();

Expand Down
6 changes: 6 additions & 0 deletions tests/unit/useReportUnreadMessageScrollTrackingTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe('useReportUnreadMessageScrollTracking', () => {
unreadMarkerReportActionIndex: -1,
isInverted: true,
onTrackScrolling: onTrackScrollingMockFn,
hasOnceLoadedReportActions: true,
}),
);

Expand Down Expand Up @@ -69,6 +70,7 @@ describe('useReportUnreadMessageScrollTracking', () => {
isInverted: true,
unreadMarkerReportActionIndex: -1,
onTrackScrolling: onTrackScrollingMockFn,
hasOnceLoadedReportActions: true,
}),
);

Expand All @@ -95,6 +97,7 @@ describe('useReportUnreadMessageScrollTracking', () => {
isInverted: true,
unreadMarkerReportActionIndex: 1,
onTrackScrolling: onTrackScrollingMockFn,
hasOnceLoadedReportActions: true,
}),
);

Expand Down Expand Up @@ -126,6 +129,7 @@ describe('useReportUnreadMessageScrollTracking', () => {
unreadMarkerReportActionIndex: -1,
isInverted: true,
onTrackScrolling: onTrackScrollingMockFn,
hasOnceLoadedReportActions: true,
}),
);

Expand All @@ -151,6 +155,7 @@ describe('useReportUnreadMessageScrollTracking', () => {
unreadMarkerReportActionIndex: 1,
isInverted: true,
onTrackScrolling: onTrackScrollingMockFn,
hasOnceLoadedReportActions: true,
}),
);

Expand Down Expand Up @@ -180,6 +185,7 @@ describe('useReportUnreadMessageScrollTracking', () => {
unreadMarkerReportActionIndex: 1,
isInverted: true,
onTrackScrolling: onTrackScrollingMockFn,
hasOnceLoadedReportActions: true,
}),
);

Expand Down
Loading