Skip to content

[$250] IOU - Expense without category appears in the first place in carousel, then moves to last place #90063

Closed

Description

@lanitochka17

If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!


Version Number: 9.3.69-0
Reproducible in staging?: Yes
Reproducible in production?: No
If this was caught during regression testing, add the test name, ID and link from BrowserStack: #86477
Email or phone of affected tester (no customers): sdjosjdoisjnoisnjio@gmail.com
Issue reported by: Applause Internal Team
Bug source: Exploratory - Significant User Experience Deterioration
Device used: Mac 26.2 / Chrome
App Component: Money Requests

Action Performed:

  1. Go to staging.new.expensify.com
  2. Go to workspace chat.
  3. Create two expenses with category.
  4. Create another expense without category.

Expected Result:

The expense without category will not change position in the expense carousel.

Actual Result:

The expense without category appears in the first place in the expense carousel, then moves to the last place.

Workaround:

Unknown

Platforms:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome / Safari

Screenshots/Videos

Bug7148559_1778259351890.17.mp4

View all open jobs on GitHub

Upwork Automation - Do Not Edit

Activity

  1. added
    DeployBlockerCashThis issue or pull request should block deployment
    BugSomething is broken. Auto assigns a BugZero manager.
    on May 8, 2026
  2. melvin-bot commented on May 8, 2026

    @melvin-bot

    You have been assigned to this deploy blocker because you recently merged this PR: #86477

  3. melvin-bot commented on May 8, 2026

    @melvin-bot

    💬 A slack conversation has been started in #expensify-open-source

  4. github-actions commented on May 8, 2026

    @github-actions
    Contributor

    👋 Friendly reminder that deploy blockers are time-sensitive ⏱ issues! Check out the open `StagingDeployCash` deploy checklist to see the list of PRs included in this release, then work quickly to do one of the following:

    1. Identify the pull request that introduced this issue and revert it.
    2. Find someone who can quickly fix the issue.
    3. Fix the issue yourself.
  5. OSBotify commented on May 8, 2026

    @OSBotify
    Contributor

    🔍 Investigation Summary

    Classification: Frontend bug
    Causing PR: #86477 - "Fix 85553 rbr carousel sorting" by @abelhailefen (High confidence)
    Related Issues: #85553

    Recommendation: REVERT

    PR #86477 introduced RBR-aware sorting directly into the carousel's useMemo for carouselTransactions. The compareByRBR comparator is applied unconditionally on every render of the carousel, which means when a transaction without a category (and therefore no RBR flag) is added, the sort re-runs and repositions it to the end of the carousel after initially rendering it in its insertion position. This explains the observed behavior: the new expense first appears at position one (optimistic insertion), then jumps to the last position (after compareByRBR sorts all RBR transactions to the front).

    Assigned: @abelhailefen (PR author), @ikevin127 and @grgia (approving reviewers)
    Labels: DeployBlockerCash kept (frontend App PR is the cause). No label changes made.

    📋 Detailed Analysis

    Evidence

    • The issue is reproducible on staging only (not production), matching version 9.3.69-0 which includes PR Fix 85553 rbr carousel sorting #86477 (merged 2026-05-07, appearing as entry Styling the comments #34 in the StagingDeployCash chronological list).
    • The melvin-bot comment on the issue also directly links PR Fix 85553 rbr carousel sorting #86477 as the causing PR.
    • The bug's description ("expense without category appears first, then moves to last") is a direct consequence of how compareByRBR interacts with optimistic transaction insertion: the expense briefly renders at the top via optimistic update, then carouselTransactions re-sorts and pushes it to the end since it has no RBR flag.

    Verification

    • Affected files: src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx and src/libs/TransactionPreviewUtils.ts
    • PR Fix 85553 rbr carousel sorting #86477 modifies exactly these files (MoneyRequestReportPreviewContent.tsx, MoneyRequestReportTransactionList.tsx, TransactionPreviewUtils.ts).
    • The carouselTransactions memo was changed from a simple .slice(0, 11) to a full sort via compareByRBR before slicing, introducing the positional flicker.

    Root Cause

    In MoneyRequestReportPreviewContent.tsx, the carouselTransactions memo now sorts all transactions via compareByRBR on every render. When a user adds an expense without a category (no RBR indicator), the sort places it after all RBR-flagged transactions. If other categorized/RBR-free transactions exist, the non-RBR transaction renders momentarily in its original order (from the optimistic Onyx update), then snaps to the back of the list once the sort completes on the next render cycle. Additionally, the compareByRBR sort in the carousel ignores the reportActionsMap parameter (it's passed as undefined), meaning it always applies, even when no RBR context is loaded — unlike the transaction list which correctly skips RBR sorting when not in default sort mode.

  6. MelvinBot commented on May 8, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    PR #86477 introduced compareByRBR sorting to the expense carousel (MoneyRequestReportPreviewContent.tsx:362). This sorts transactions with RBR indicators (violations) to the front.

    When a user creates an expense without a category, the client creates an optimistic missingCategory violation with showInReview: true (ViolationsUtils.ts:419). This causes transactionHasRBR to return true (TransactionPreviewUtils.ts:484), since hasViolation filters for showInReview === true (TransactionUtils/index.ts:2022). The expense sorts to position 1 (first).

    When the server responds, it replaces the optimistic violation data. If the server's missingCategory violation has showInReview: false (or omits the field — which defaults to false), then hasViolation(..., showInReview=true) returns false, RBR disappears, and compareByRBR returns 0 for all transactions. The expense falls to its natural date-based position (last, since it's newest).

    A secondary contributing factor: the transaction and its violations are tracked by separate useOnyx hooks, so there may be an intermediate render where the new transaction exists but its violations haven't propagated yet — momentarily showing no RBR and causing the position jump.

    What changes do you think we should make in order to solve the problem?

    The carousel sort should be stable — when compareByRBR returns 0 (equal RBR status), transactions should retain their previous relative order rather than shifting. This can be done by preserving the original array index as a tiebreaker in the sort at MoneyRequestReportPreviewContent.tsx:362-364 and in the transaction list sort at MoneyRequestReportTransactionList.tsx:296-316.

    Additionally, if the server is expected to return missingCategory with showInReview: true but isn't, that server-side gap should be addressed to prevent RBR from flickering off after sync.

    What alternative solutions did you explore? (Optional)

    An alternative would be to ensure the optimistic violation data exactly matches what the server returns (so no RBR state change occurs on sync). However, this is fragile — any future mismatch would reintroduce the same problem. A stable sort is more robust and prevents visual jumps regardless of violation state changes.


    Next Steps for Contributor+ team: Reply with @MelvinBot implement this to create a draft PR, @MelvinBot <your feedback> to refine this analysis, or explain why you are rejecting Melvin's proposal.

  7. ikevin127 commented on May 9, 2026

    @ikevin127
    Contributor

    @grgia I wouldn't label this a blocker, it's a UX regression that's not blocking user flow in any breaking way.

    @abelhailefen Looks like the PR had 1 regression (this issue), the procedure in this case can go two ways:

    • if the PR was reverted due to blocking deploy: the original PR author should reopen a rework with the initial PRs changes + the fix for the regression that was found
    • if the PR is not an acctual deploy blocker (this doesn't look like a blocker, but CME should confirm this): the original PR author should open a follow-up PR with a fix for the regression (this issue)

    Keep an eye on the PR / this issue, if it's reverted then a rework is in order, otherwise a follow-up PR fixing the issue 🙌

  8. 22 remaining items

  9. MelvinBot commented on May 21, 2026

    @MelvinBot
    Contributor

    Review of Proposed Solution

    Diff 1 — Date tiebreaker in carousel sort ✅

    This is a solid improvement. When compareByRBR returns 0 (same RBR status), transactions currently have undefined relative order. Adding a getCreated date tiebreaker makes the sort deterministic and fixes the secondary date-editing issue. No concerns here.

    Diff 2 — Default category to "Uncategorized" in MoneyRequestBuilder ⚠️

    The root cause analysis is correct. Here's the mechanism:

    1. Expense created with "" category → !categoryKey is true → optimistic missingCategory violation added (ViolationsUtils.ts:419) → RBR = true → sorted first
    2. Category set to "Uncategorized" → !categoryKey is now false → optimistic violation removed → RBR disappears → expense jumps to the back

    Setting category to "Uncategorized" from the start prevents this RBR flip. The fix is viable, but has three concerns:

    1. Wrong constant — CONST.SEARCH.CATEGORY_DEFAULT_VALUE is search-specific

    This constant lives under SEARCH and is only used in CategoryUtils.ts:136 for search filtering. Using it in transaction creation couples expense creation to search display logic. If this value ever changes for search reasons, it would silently break expense creation. Consider defining a dedicated constant (e.g., CONST.TRANSACTION.DEFAULT_CATEGORY) or at minimum adding a code comment explaining the coupling.

    2. Only one code path is patched

    The fix only covers getMoneyRequestInformation in MoneyRequestBuilder.ts. But expenses can also be created via:

    If the same rapid-creation scenario is possible via split expenses, the jump would persist there. Recommend auditing those paths too — or better yet, applying the default in buildOptimisticTransaction (where category defaults to ''), which is the single funnel point for all expense creation. That would be a smaller, more robust fix.

    3. Optimistic vs backend violation mismatch

    After this fix, an expense created with "Uncategorized" won't get an optimistic missingCategory violation (because !categoryKey is false). But the backend WILL add one (because "Uncategorized" is still considered missing). This means RBR will appear when the BE syncs — but since it'll apply to all uncategorized expenses simultaneously, their relative order won't jump. This is acceptable, but worth noting: there's a brief window where no RBR shows even though the category is effectively missing.

    Verdict

    Both diffs together form a viable solution for the reported issue. The date tiebreaker (Diff 1) is clean and ready as-is. The category default (Diff 2) correctly addresses the root cause but would benefit from:

    1. Using a transaction-specific constant instead of CONST.SEARCH.CATEGORY_DEFAULT_VALUE
    2. Applying the default in buildOptimisticTransaction instead of getMoneyRequestInformation to cover all expense creation paths in one place
    3. Testing the split-expense path to confirm it's not affected
  10. ikevin127 commented on May 22, 2026

    @ikevin127
    Contributor

    @MelvinBot Draft a PR for this issue using the following solution:

    diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx
    index 792122cc11a..3193f0e8d29 100644
    --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx
    +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx
    @@ -57,7 +57,7 @@ import {startSpan} from '@libs/telemetry/activeSpans';
     import {getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction';
     import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
     import {compareByRBR} from '@libs/TransactionPreviewUtils';
    -import {hasPendingUI, isManagedCardTransaction, isPending} from '@libs/TransactionUtils';
    +import {getCreated, hasPendingUI, isManagedCardTransaction, isPending} from '@libs/TransactionUtils';
     import colors from '@styles/theme/colors';
     import variables from '@styles/variables';
     import CONST from '@src/CONST';
    @@ -160,7 +160,7 @@ function MoneyRequestReportPreviewContent({
         const styles = useThemeStyles();
         const StyleUtils = useStyleUtils();
         const currentUserDetails = useCurrentUserPersonalDetails();
    -    const {translate, formatPhoneNumber} = useLocalize();
    +    const {translate, formatPhoneNumber, localeCompare} = useLocalize();
         const {convertToDisplayString} = useCurrencyListActions();
         // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
         const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
    @@ -357,11 +357,16 @@ function MoneyRequestReportPreviewContent({
             if (shouldShowAccessPlaceHolder) {
                 return [];
             }
    -        const sorted = [...transactions].sort((a, b) =>
    -            compareByRBR(a, b, transactionViolations, currentUserDetails?.login ?? '', currentUserDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID, iouReport, policy),
    -        );
    +        const sorted = [...transactions].sort((a, b) => {
    +            const rbrComparison = compareByRBR(a, b, transactionViolations, currentUserDetails?.login ?? '', currentUserDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID, iouReport, policy);
    +            if (rbrComparison !== 0) {
    +                return rbrComparison;
    +            }
    +            // Tiebreak by date (ascending — oldest first) so position is stable across RBR state changes
    +            return localeCompare(getCreated(a), getCreated(b));
    +        });
             return sorted.slice(0, 11);
    -    }, [shouldShowAccessPlaceHolder, transactions, transactionViolations, currentUserDetails?.login, currentUserDetails?.accountID, iouReport, policy]);
    +    }, [shouldShowAccessPlaceHolder, transactions, transactionViolations, currentUserDetails?.login, currentUserDetails?.accountID, iouReport, policy, localeCompare]);
         const prevCarouselTransactionLength = useRef(0);
     
         useEffect(() => {
    diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts
    index c41e65cd11f..59c9dc9c5af 100644
    --- a/src/libs/TransactionUtils/index.ts
    +++ b/src/libs/TransactionUtils/index.ts
    @@ -429,7 +429,9 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T
             created = '',
             merchant = '',
             receipt,
    -        category = '',
    +        // Prevent RBR flip and transaction jump: initialize category to 'Uncategorized' instead of
    +        // empty string so optimistic missing category violation isn't added then removed during backend sync
    +        category = CONST.SEARCH.CATEGORY_DEFAULT_VALUE,
             tag = '',
             taxCode = '',
             taxAmount = 0,

    This was tested manually and it fixes the issue as expected, I went with your suggestion of applying the default in buildOptimisticTransaction (where category defaults to ''), which is the single funnel point for all expense creation. The CONST.SEARCH.CATEGORY_DEFAULT_VALUE is correct here, it won't change and the "Optimistic vs backend violation mismatch" is not an issue.

    Make sure to add the manual tests from this issue, with verifying the expected result as last testing step.

  11. MelvinBot commented on May 22, 2026

    @MelvinBot
    Contributor

    🤖 Draft PR created: #91467
    Please mark it as "Ready for review" when it is ready for review.

  12. changed the title [-]IOU - Expense without category appears in the first place in carousel, then moves to last place[/-] [+][$250] IOU - Expense without category appears in the first place in carousel, then moves to last place[/+] on May 22, 2026
  13. melvin-bot commented on May 22, 2026

    @melvin-bot
  14. ikevin127 commented on May 22, 2026

    @ikevin127
    Contributor

    Payment will be handled in initial issue as this is a regression issue of that initial one.

  15. melvin-bot commented on Jun 15, 2026

    @melvin-bot

    This issue has not been updated in over 15 days. @grgia, @ikevin127 eroding to Monthly issue.

    P.S. Is everyone reading this sure this is really a near-term priority? Be brave: if you disagree, go ahead and close it out. If someone disagrees, they'll reopen it, and if they don't: one less thing to do!

  16. ikevin127 commented on Jun 15, 2026

    @ikevin127
    Contributor

    @grgia This issue should be closed as resolved. More details can be found in the initial issue for confirmation.

  17. grgia commented on Aug 10, 2026

    @grgia
    Contributor

    Confirmed, fixed by #91467 and payment settled in #85553. Closing, thanks @ikevin127!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.EngineeringMonthlyKSv2ReviewingHas a PR in review

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions