Skip to content
Open
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
6 changes: 4 additions & 2 deletions src/pages/home/YourSpendSection/SpendSummaryRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ type SpendSummaryRowProps = {
// Position of this row within the Your spend list. Used to vary the skeleton
// title width across stacked rows, mirroring `ForYouSkeleton`.
skeletonRowIndex: number;
// Greys the total when a queued offline change may have made it stale.
isStale?: boolean;

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.

❌ CONSISTENCY-10 (docs)

The new isStale prop on SpendSummaryRowProps is documented with a // line comment. Per STYLE.md, component props must be documented with a /** ... */ block comment above the member, not a // comment.

    /** Greys the total when a queued offline change may have made it stale. */
    isStale?: boolean;

Reviewed at: 4e38e04 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

};

function SpendSummaryRow({state, testIDPrefix, description, totals, iconSrc, onPress, wrapperStyle, skeletonRowIndex}: SpendSummaryRowProps) {
function SpendSummaryRow({state, testIDPrefix, description, totals, iconSrc, onPress, wrapperStyle, skeletonRowIndex, isStale = false}: SpendSummaryRowProps) {
const styles = useThemeStyles();
const theme = useTheme();
const {shouldUseNarrowLayout} = useResponsiveLayout();
Expand Down Expand Up @@ -108,7 +110,7 @@ function SpendSummaryRow({state, testIDPrefix, description, totals, iconSrc, onP
<MenuItemWithTopDescription
description={description}
title={totals.total !== undefined ? convertToDisplayString(totals.total, totals.currency) : undefined}
titleStyle={styles.textBold}
titleStyle={[styles.textBold, isStale && styles.offlineFeedbackPending]}
onPress={onPress}
shouldShowRightIcon
leftComponent={
Expand Down
4 changes: 3 additions & 1 deletion src/pages/home/YourSpendSection/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import SpendSummaryRow from './SpendSummaryRow';
import {useYourSpendData, YOUR_SPEND_ROW_STATE} from './useYourSpendData';

function YourSpendSection() {
const {approvalRowState, approvalTotals, paymentRowState, paymentTotals, cardRows, awaitingApprovalQuery, repaidLast30DaysQuery} = useYourSpendData();
const {approvalRowState, approvalTotals, paymentRowState, paymentTotals, cardRows, awaitingApprovalQuery, repaidLast30DaysQuery, isApprovalStale, isPaymentStale} = useYourSpendData();
const {translate} = useLocalize();
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useResponsiveLayout();
Expand Down Expand Up @@ -63,6 +63,7 @@ function YourSpendSection() {
onPress={() => Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: awaitingApprovalQuery}))}
wrapperStyle={wrapperStyle}
skeletonRowIndex={0}
isStale={isApprovalStale}
/>

<SpendSummaryRow
Expand All @@ -74,6 +75,7 @@ function YourSpendSection() {
onPress={() => Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: repaidLast30DaysQuery}))}
wrapperStyle={wrapperStyle}
skeletonRowIndex={1}
isStale={isPaymentStale}
/>

{visibleCardRows.map((cardRow) => (
Expand Down
135 changes: 133 additions & 2 deletions src/pages/home/YourSpendSection/useYourSpendData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';

import {search} from '@libs/actions/Search';
import {WRITE_COMMANDS} from '@libs/API/types';
import {getDisplayableExpensifyCards, getDisplayableThirdPartyCards, isPersonalCard, lastFourNumbersFromCardName} from '@libs/CardUtils';
import {arePaymentsEnabled, isPaidGroupPolicy} from '@libs/PolicyUtils';
import {buildSearchQueryJSON} from '@libs/SearchQueryUtils';
Expand All @@ -12,6 +13,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Card, Policy, Report} from '@src/types/onyx';
import type {CardFeedWithNumber} from '@src/types/onyx/CardFeeds';
import type {AnyRequest} from '@src/types/onyx/Request';
import type SearchResults from '@src/types/onyx/SearchResults';

import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
Expand Down Expand Up @@ -95,6 +97,10 @@ type UseYourSpendDataReturn = {
cardRows: YourSpendCardRow[];
awaitingApprovalQuery: string;
repaidLast30DaysQuery: string;
// True when offline with a queued change that would move this specific total,
// so its row renders greyed out until the next online refresh.
isApprovalStale: boolean;
isPaymentStale: boolean;
};

function getOutstandingReportsSignature(reports: OnyxCollection<Report> | undefined, paidGroupPolicyIDs: string[], accountID: number): string {
Expand All @@ -117,6 +123,95 @@ function getOutstandingReportsSignature(reports: OnyxCollection<Report> | undefi
return ids.sort().join(',');
}

// Offline queue commands that move each "Your spend" total. Read from the action
// queue rather than inferred from a report's status, because a report can pass
// through several states offline (e.g. approve then pay) and only its final status
// would otherwise survive — dropping the earlier action's stale signal.
const YOUR_SPEND_APPROVAL_COMMANDS = new Set<string>([
WRITE_COMMANDS.SUBMIT_REPORT,
WRITE_COMMANDS.RETRACT_REPORT,
WRITE_COMMANDS.APPROVE_MONEY_REQUEST,
WRITE_COMMANDS.UNAPPROVE_EXPENSE_REPORT,
]);
const YOUR_SPEND_PAYMENT_COMMANDS = new Set<string>([WRITE_COMMANDS.PAY_MONEY_REQUEST, WRITE_COMMANDS.PAY_MONEY_REQUEST_WITH_WALLET, WRITE_COMMANDS.CANCEL_PAYMENT]);

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 Include MarkReportPaymentReceived in paid staleness

When the submitter uses the Received payment secondary action while offline, markReportPaymentReceived queues WRITE_COMMANDS.MARK_REPORT_PAYMENT_RECEIVED with reportID and optimistically moves the report to REIMBURSED, so it enters the status:paid query. This set filters that request out, and the projection only treats pendingFields.total as an amount change, so isPaymentStale stays false and the Repaid row shows an ungreyed stale snapshot until reconnect. Add MARK_REPORT_PAYMENT_RECEIVED to the payment bucket.

Useful? React with 👍 / 👎.


type YourSpendPendingBuckets = {
// A queued offline change would move the "Awaiting approval" (status:outstanding) total.
approval: boolean;
// A queued offline change would move the "Repaid last 30 days" (status:paid) total.
payment: boolean;
};

// Status of each report the user owns on a paid group workspace, plus the totals an
// amount change (edit / delete / reject; marked by `pendingFields.total`) would move.
// Amount changes keep a report in the same bucket, so the report's current status
// classifies them; state transitions are handled from the queue instead.
type YourSpendReportsProjection = {
// reportID -> statusNum, used to scope queued commands to the user's own paid reports.
statusByID: Record<string, number>;
amount: YourSpendPendingBuckets;
};

function projectYourSpendReports(reports: OnyxCollection<Report> | undefined, paidGroupPolicyIDs: string[], accountID: number): YourSpendReportsProjection {
const projection: YourSpendReportsProjection = {statusByID: {}, amount: {approval: false, payment: false}};
if (!reports || paidGroupPolicyIDs.length === 0) {
return projection;
}
const policyIDSet = new Set(paidGroupPolicyIDs);
for (const report of Object.values(reports)) {
if (report?.ownerAccountID !== accountID || !report?.policyID || !policyIDSet.has(report.policyID) || report.statusNum === undefined) {
continue;
}
projection.statusByID[report.reportID] = report.statusNum;
if (report.pendingFields?.total == null) {
continue;
}
if (report.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED) {
projection.amount.approval = true;
} else if (report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED) {
projection.amount.payment = true;
}
}
return projection;
}

// Which "Your spend" totals a queued offline change would move. The totals come
// from server-computed search snapshots we cannot recompute offline, so instead of
// patching a value we can't trust we detect that a relevant change is pending and
// grey only the affected total until the next online refresh.
//
// State transitions (submit / retract / approve / unapprove / pay / cancel) are read
// from the offline action queue: every queued command persists for the whole offline
// session, so a report that was approved and then paid keeps BOTH signals. Amount
// changes (edit / delete / reject) don't move a report between buckets, so they're
// classified by the report's current status (see projectYourSpendReports).
function getYourSpendPendingBuckets(projection: YourSpendReportsProjection, queuedRequests: AnyRequest[] | undefined): YourSpendPendingBuckets {
const buckets: YourSpendPendingBuckets = {approval: projection.amount.approval, payment: projection.amount.payment};
for (const request of queuedRequests ?? []) {
const isApprovalCommand = YOUR_SPEND_APPROVAL_COMMANDS.has(request.command);
const isPaymentCommand = YOUR_SPEND_PAYMENT_COMMANDS.has(request.command);
if (!isApprovalCommand && !isPaymentCommand) {
continue;
}
// Report-level commands carry `reportID`; money-request commands (e.g. pay) carry `iouReportID`.
const rawReportID = request.data?.reportID ?? request.data?.iouReportID;
const reportID = typeof rawReportID === 'string' ? rawReportID : undefined;
// Only count commands acting on one of the user's own paid-group reports.
if (!reportID || projection.statusByID[reportID] === undefined) {
continue;
}
if (isApprovalCommand) {
buckets.approval = true;
} else {
buckets.payment = true;
}
if (buckets.approval && buckets.payment) {
break;
}
}
return buckets;
}

function getYourSpendRowState({isApplicable, isOffline, searchResults}: GetYourSpendRowStateParams): YourSpendRowState {
if (!isApplicable) {
return YOUR_SPEND_ROW_STATE.HIDDEN;
Expand Down Expand Up @@ -162,6 +257,21 @@ function useYourSpendData(): UseYourSpendDataReturn {
selector: (reports) => getOutstandingReportsSignature(reports, paidGroupPolicyIDs, accountID),
});

// Which totals a queued offline change would move. When offline we can't
// refresh the snapshots, so we grey only the affected total to signal it may
// be stale rather than showing a value we know might be wrong.
const [reportsProjection] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {
selector: (reports) => projectYourSpendReports(reports, paidGroupPolicyIDs, accountID),

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-11 (docs)

This useOnyx selector on the REPORT collection returns projectYourSpendReports(...), whose statusByID field is a Record<string, number> that grows with the number of the user's paid-group reports. Onyx runs deepEqual on this output on every REPORT collection change (a very hot key), and the result is an intermediate structure that getYourSpendPendingBuckets only reduces further. On a high-traffic account this is an expensive deepEqual with no re-render savings — exactly the anti-pattern this rule warns about.

Mirror the sibling outstandingReportsSignature selector above, which reduces to a compact primitive. Compute the final booleans in the selector (or a compact string signature) rather than returning a growing statusByID map, e.g.:

const [reportsProjection] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {
    // return a small, stable signature/booleans instead of a per-report Record
    selector: (reports) => projectYourSpendReportsSignature(reports, paidGroupPolicyIDs, accountID),
});

Reviewed at: 4e38e04 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

});
const [queuedSpendRequests] = useOnyx(ONYXKEYS.PERSISTED_REQUESTS, {
selector: (requests) =>
(requests ?? []).filter((request) => !!request?.command && (YOUR_SPEND_APPROVAL_COMMANDS.has(request.command) || YOUR_SPEND_PAYMENT_COMMANDS.has(request.command))),

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-11 (docs)

This selector on PERSISTED_REQUESTS returns a filtered array of full AnyRequest objects. Each request carries large nested payloads (optimisticData, successData, failureData, finallyData, etc.), so Onyx's deepEqual on this array runs a deep comparison over every field of every matching request on every queue mutation. This is the "filters/maps a collection into an array — deepEqual on every item" case the rule flags.

Project each request down to only the fields consumed downstream (command and the report ID) so the compared output is small:

const [queuedSpendRequests] = useOnyx(ONYXKEYS.PERSISTED_REQUESTS, {
    selector: (requests) =>
        (requests ?? [])
            .filter((r) => !!r?.command && (YOUR_SPEND_APPROVAL_COMMANDS.has(r.command) || YOUR_SPEND_PAYMENT_COMMANDS.has(r.command)))
            .map((r) => ({command: r.command, reportID: r.data?.reportID ?? r.data?.iouReportID})),
});

(and adjust getYourSpendPendingBuckets to read the projected shape).


Reviewed at: 4e38e04 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

});
const pendingSpendBuckets = useMemo(
() => getYourSpendPendingBuckets(reportsProjection ?? {statusByID: {}, amount: {approval: false, payment: false}}, queuedSpendRequests),
[reportsProjection, queuedSpendRequests],
);

// Destructure here so downstream memos depend only on the sub-records, not on
// the parent value that's rebuilt on every CARD_FEED_ERRORS tick.
const {cardsWithBrokenFeedConnection, personalCardsWithBrokenConnection} = useCardFeedErrors();
Expand Down Expand Up @@ -428,8 +538,29 @@ function useYourSpendData(): UseYourSpendDataReturn {
cardRows,
awaitingApprovalQuery,
repaidLast30DaysQuery,
isApprovalStale: isOffline && !!pendingSpendBuckets?.approval,
isPaymentStale: isOffline && !!pendingSpendBuckets?.payment,
};
}

export {YOUR_SPEND_CARD_KIND, YOUR_SPEND_ROW_STATE, getOutstandingReportsSignature, getYourSpendApplicability, getYourSpendRowState, useYourSpendData};
export type {GetYourSpendRowStateParams, UseYourSpendDataReturn, YourSpendApplicability, YourSpendCardKind, YourSpendCardRow, YourSpendRowState, YourSpendRowTotals};
export {
YOUR_SPEND_CARD_KIND,
YOUR_SPEND_ROW_STATE,
getOutstandingReportsSignature,
getYourSpendApplicability,
getYourSpendPendingBuckets,
getYourSpendRowState,
projectYourSpendReports,
useYourSpendData,
};
export type {
GetYourSpendRowStateParams,
UseYourSpendDataReturn,
YourSpendApplicability,
YourSpendCardKind,
YourSpendCardRow,
YourSpendPendingBuckets,
YourSpendReportsProjection,
YourSpendRowState,
YourSpendRowTotals,
};
Loading
Loading