Skip to content

Let an explicit column sort win over the RBR ordering and the alphabetical group order - #101497

Draft
MelvinBot wants to merge 4 commits into
mainfrom
claude-reportSortRbrAndGroupOrder
Draft

MelvinBot wants to merge 4 commits into
mainfrom
claude-reportSortRbrAndGroupOrder

Conversation

@MelvinBot

Copy link
Copy Markdown
Contributor

Explanation of Change

An explicit column press now wins over both the RBR ordering and the alphabetical group order, so the Date header produces a real chronological sort while RBR-flagged expenses still surface on first open.

Two mechanisms overrode the date sort. The comparator itself was correct.

  1. isDefaultSort was exactly Date + ASC, which is both the initial state and where every second Date press lands, so the RBR hoist re-applied itself and ascending never came out chronological. A hasUserSortedTransactions flag, set in onSortPress, is folded straight into isDefaultSort rather than carried as a second condition that could drift from it.
  2. sortGroupedTransactions ordered the group headers by name with no reference to the active sort, and the rendered list is built from the groups, so date order only survived inside each category block. It now takes an optional compareLeadingTransactions comparator, which groupTransactionsByCategory and groupTransactionsByTag forward. The rows reach the grouping already sorted, so each group's first row is its leading row under the active sort and reusing the row comparator gives coherent group ordering: Date ASC orders groups by earliest date, Date DESC by latest, text columns stay effectively alphabetical. The empty-key rule becomes the tiebreak instead of a hard pin, so Uncategorized can leave the bottom when the sort puts it first.

Grouping is never bypassed, so the user's server-backed NVP_REPORT_LAYOUT_GROUP_BY choice is untouched. That also keeps the RHP prev/next arrows on the visible row order: visualOrderTransactionIDs flat-maps groupedTransactions whenever grouping is active, so changing the group order rather than flattening keeps the arrow order identical to the rendered rows. sortGroupedTransactions also no longer sorts its argument in place.

The report preview carousel keeps its own RBR-first ordering, and narrow layouts have no sortable column header, so both are unchanged.

Behaviour agreed in the linked issue and confirmed internally: RBR-first on first open stays, the table then follows the selected column, and the arrows follow the table.

AI Tests

Run locally by MelvinBot on this branch:

  • npm test -- tests/unit/ReportLayoutUtilsTest.ts — passed (42 tests, including 8 new group-ordering cases)
  • npm test for the related suites MoneyRequestReportTransactionListActiveTransactionIDsTest, MoneyRequestReportTransactionsNavigationTest, MoneyRequestReportGroupHeaderTest, MoneyRequestReportViewTest, MoneyRequestReportActionsListRejectModalTest, MoneyRequestReportTransactionItemRejectErrorTest — all passed
  • npm run typecheck — passed
  • npm run lint-changed — passed
  • npm run spell-changed — passed (3 files, 0 issues)
  • npm run react-compiler-compliance-check check <MoneyRequestReportTransactionList.tsx> — fails identically on main (pre-existing "missing/extra memoization dependencies"), so no regression; this file is not compiler-memoized, which is why the new useMemo is manual like the ones around it

Browser verification could not be run: the handed-off web session rendered a blank page for the whole run (0 accessibility nodes, no network activity), so no UI screenshots are attached.

Fixed Issues

$ #101424
PROPOSAL: #101424 (comment)

Tests

// TODO: The human co-author must fill out the tests they ran before marking this PR as "ready for review".
// Please describe what tests you performed that validate your change worked.

  • Verify that no errors appear in the JS console

Offline tests

// TODO: The human co-author must fill out the offline tests they ran before marking this PR as "ready for review".

QA Steps

// TODO: These must be filled out, or the issue title must include "[No QA]."
// TODO: The human co-author must fill out the QA steps before marking this PR as "ready for review". Please describe what QA needs to do to validate these changes and which areas they need to check for regressions.

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/libs/ReportLayoutUtils.ts 100.00% <100.00%> (+1.96%) ⬆️
...stReportView/MoneyRequestReportTransactionList.tsx 65.51% <93.75%> (+65.15%) ⬆️
... and 26 files with indirect coverage changes

const isDefaultSort = sortBy === CONST.SEARCH.TABLE_COLUMNS.DATE && sortOrder === CONST.SEARCH.SORT_ORDER.ASC;
// Date/ASC is both the initial state and where every second Date press lands, so pressing a column has to be
// tracked separately for an explicit sort to win over the RBR ordering below.
const [hasUserSortedTransactions, setHasUserSortedTransactions] = useState(false);

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.

🟠 hasUserSortedTransactions is never reset, so sorting one report can suppress RBR-first ordering on the next report you open. The flag is plain useState with no reset path. The component is reused across reportID changes rather than remounted, which is exactly why :364-368 exists:

useEffect(() => {
    clearSelectedTransactions(true);
    // eslint-disable-next-line react-hooks/exhaustive-deps
}, [reportID]);

If selections had to be cleared manually on reportID change, hasUserSortedTransactions does too. There is also no key on the usage at MoneyRequestReportActionsList.tsx:555.

Concrete failure: open report A, press Date twice so you are back on Date ↑, navigate to report B which has violation-flagged expenses. On main, isDefaultSort is true for B and the RBR expenses surface first. With this PR, hasUserSortedTransactions is still true, so B opens in plain chronological order and the RBR expenses are buried.

That directly contradicts the contract agreed in the issue, "RBR-first on first open stays", and it is the exact failure mode #85553 was opened to fix.

useEffect(() => {
    clearSelectedTransactions(true);
    setHasUserSortedTransactions(false);
    // eslint-disable-next-line react-hooks/exhaustive-deps
}, [reportID]);

The reset is cheap and correct either way.

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.

Fixed — you're right, and the leak is a bit wider than the flag.

sortConfig was never reset either, so on main sorting report A to Date ↓ already carried into report B and suppressed its RBR-first open. Resetting only hasUserSortedTransactions would also have left B in an incoherent state: rows in A's sort order, group headers back to alphabetical. So both reset together, giving the next report a real first open.

src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx:392

One deviation from your snippet: putting it in the [reportID] effect trips react-hooks/set-state-in-effect, which is an error in npm run lint. It's the render-time reset pattern instead, so the new report never paints with the old report's sort.

Covered by tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx:372 — it fails without the reset.


view run


// Once the user presses a column the group headers follow that column too, otherwise the groups stay alphabetical
// and only the rows inside each group would be ordered.
const compareLeadingTransactions: CompareLeadingTransactions | undefined = useMemo(() => {

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.

🟠 compareLeadingTransactions re-introduces the full report and policy objects into groupedTransactions, defeating the optimization the comment right below it describes. The existing memo deliberately narrows to report?.reportID and report?.currency:

// We skip including the report as a dependency to avoid unnecessary re-renders as it changes often and we only need to recalculate when currency changes.

The new comparator memo depends on report and policy wholesale, and is itself now a dependency of groupedTransactions.

Every time the report object changes identity, which that comment says happens often, compareLeadingTransactions gets a new reference, which invalidates groupedTransactions, which re-runs groupTransactionsByCategory over every transaction plus calculateGroupTotal per group. The narrowing is bypassed.

getTransactionSortValue (ReportUtils.ts:14110-14160) only reads isExpenseReport(report) and report?.currency from the report, so the same narrowing applies. Match the convention already in the file:

}, [hasUserSortedTransactions, sortBy, sortOrder, report?.reportID, report?.currency, policy?.id, policyCategories, policyTagLists, localeCompare]);

with the same eslint-disable-next-line react-hooks/exhaustive-deps and a one-line reason, or build the comparator inside the groupedTransactions memo so it inherits the narrowed list.

Without this, a large report re-buckets and re-totals on unrelated report updates such as an incoming comment or a status change.

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.

Changed — comparator now lives inside the groupedTransactions memo, so it inherits the narrowed dependency list instead of adding a second memo keyed on the whole report.

src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx:525

One correction on the impact, since it changes how the fix should be read: the narrowing on groupedTransactions was already bypassed before this PR. sortedTransactions depends on the full report and policy at MoneyRequestReportTransactionList.tsx:440, resolvedTransactions derives from it, and groupedTransactions depends on resolvedTransactions. So an incoming comment re-buckets and re-totals on main too — the comparator memo wasn't adding that.

That's also why the narrowed list can't go stale here: any report or policy change already invalidates this memo through resolvedTransactions. Worth noting getTransactionSortValue reads more than isExpenseReport(report) and report?.currencygetReportCustomColumnValue(key, report) for the submitter and deal-number columns — so report?.reportID alone wouldn't be safe without that upstream dependency.


view run

if (compareLeadingTransactions) {
const leadingA = a.transactions.at(0);
const leadingB = b.transactions.at(0);
if (leadingA && leadingB) {

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 leadingA && leadingB guard silently falls through to alphabetical for an empty group. Correct in practice since groupTransactionsByCategory only creates a group when it pushes a transaction, so a group is never empty. Worth a short comment saying so, otherwise a future reader cannot tell whether the guard is defensive or load-bearing.

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.

Added — the guard is now labelled as defensive, with the reason a group is never empty.

src/libs/ReportLayoutUtils.ts:30


view run

@@ -25,6 +28,15 @@ const createMockReport = (overrides: Partial<Report> = {}): Report =>
...overrides,
}) as Report;

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 promised RHP arrow regression test is missing. The approved proposal committed to it explicitly:

Covered by a regression assertion in tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx: sort, then assert the arrow order equals the rendered row order.

That file exists in the repo but is not in this diff. What shipped instead is a pure-function test in ReportLayoutUtilsTest.ts, which never exercises visualOrderTransactionIDs, setActiveTransactionIDs, or the navigation component. The arrow-order guarantee is the main argument for threading the sort rather than flattening, so it is the thing most worth protecting from a future refactor.

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.

Added — tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx:349, in the file the proposal named.

It renders the real MoneyRequestReportTransactionList (the unified list is stubbed so the controller it hands down is readable), presses the Date header through the real onSortPress, and asserts the seeded carousel equals the rendered rows. visualOrderTransactionIDs, the real setActiveTransactionIDs, and the TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS key the arrows read are all in the path — nothing about the order is recomputed in the test.

Fixture: four expenses across Meals and Travel, with the newest expense in the alphabetically-last category, so the three candidate orders are all distinct:

Rendered order
First open (Date ↑, groups alphabetical) 1, 3, 2, 4
Date ↓ with group ordering 4, 2, 3, 1
Date ↓ flattened (the bug being guarded) 4, 3, 2, 1

Mutation-checked both ways — the test fails when compareLeadingTransactions is forced to undefined, and when visualOrderTransactionIDs is made to flatten sortedTransactions instead of the groups.


view run


const result = groupTransactionsByCategory(transactions, report, mockLocaleCompare, compareByCreated(CONST.SEARCH.SORT_ORDER.DESC));

expect(result.flatMap((group) => group.transactions.map((transaction) => transaction.transactionID))).toEqual(['4', '2', '3', '1']);

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 test named flattens back into the sorted row order does not assert that, and the name is actively misleading. Input rows are ['4', '3', '2', '1'] (already sorted DESC). The assertion is:

expect(result.flatMap((group) => group.transactions.map((transaction) => transaction.transactionID))).toEqual(['4', '2', '3', '1']);

['4','2','3','1'] is not the sorted row order, it is the rendered order once rows are bucketed by category. The assertion is right and the behaviour is correct, but the name claims the opposite. A future contributor reading the name could "fix" the expectation to ['4','3','2','1'] and break grouping. Rename to flattens back into the rendered row order and keep the existing comment explaining why the arrows depend on it.

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.

Renamed to flattens back into the rendered row order, and added a line at the assertion saying ['4', '3', '2', '1'] would be asserting grouping is bypassed — so the trap you described is called out where someone would fall into it.

tests/unit/ReportLayoutUtilsTest.ts:563


view run

Comment thread tests/unit/ReportLayoutUtilsTest.ts Outdated
// Stands in for the date comparator the transaction list builds from the active sort, so the groups can be checked
// against the same ordering the rows use.
const compareByCreated =
(sortOrder: ValueOf<typeof CONST.SEARCH.SORT_ORDER>): CompareLeadingTransactions =>

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.

🟡 ValueOf<typeof CONST.SEARCH.SORT_ORDER> re-derives a type that already exists in production code. SortOrder is exported from @components/Search/types and is what the sibling component uses (MoneyRequestReportTableHeaderRow.tsx:4).

Re-deriving it in a test also pulls type-fest into the file for no reason. Import the real one so the test moves with the production type if it ever changes:

import type {SortOrder} from '@components/Search/types';

const compareByCreated = (sortOrder: SortOrder): CompareLeadingTransactions => ...

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.

Done — imports SortOrder from @components/Search/types, and type-fest is out of the file.

tests/unit/ReportLayoutUtilsTest.ts:33


view run

@ikevin127

Copy link
Copy Markdown
Contributor

@MelvinBot Address the 6 code-comment that I posted above.

@MelvinBot

MelvinBot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Web test steps verification results: ⚠️ partial

All six behavioural verifications (1–6) pass on dev NewDot web: the RBR-first hoist is preserved on first open, both Date-header presses produce true chronological/reverse-chronological order with no hoist re-applied, group headers order by date rather than alphabetically, and the RHP prev/next arrows follow the visible row order. Verification 7 (no JS console errors) could not be executed because console capture is not supported by agent-device on web.
RHP on the last row (250.00 Bravo Mart) with next disabled, alongside the ascending table

Fixture: workspace Mel Tester's Workspace, report Expense Report 2026-09-01 with 4 expenses across 2 categories — Sep 1 Alpha Store (Utilities), Sep 5 Bravo Mart (Advertising), Sep 10 Charlie Depot (Utilities, on hold → RBR), Sep 15 Delta Supply (Utilities). The RBR expense sits in the middle of its group's date range so "hoisted" vs "true date order" is distinguishable in both directions, and the category names were picked so date order and alphabetical order disagree.

Step Status
VERIFY 1 — On first open the table is Date ascending and the RBR-flagged expense appears firstOpened the report fresh from Spend > Reports. Date header shows the ascending arrow. Within the Utilities group: Sep 10 Charlie Depot (RBR, "This expense was put on hold."), then Sep 1 Alpha Store, then Sep 15 Delta Supply — the RBR row is hoisted out of date order to the top of its group while the rest stay ascending. Note: with Group by: Category active, the RBR row is first within its group, not the first row of the whole table (the Advertising group header precedes it).
First open of the report: Date ascending, RBR/on-hold Sep 10 Charlie Depot hoisted to the top of the Utilities group ahead of Sep 1 Alpha Store
VERIFY 2 — One press of the Date header gives true reverse-chronological order with no RBR expense hoistedArrow flips to descending. Utilities group: Sep 15 Delta Supply, Sep 10 Charlie Depot (RBR), Sep 1 Alpha Store — the RBR row sits in its true date position (2nd), not at the top. Advertising group: Sep 5 Bravo Mart. Conclusive because the RBR expense is not the newest in its group.
Date descending: Sep 15, Sep 10 (RBR/on hold), Sep 1 in the Utilities group - RBR row in true date position; Utilities group header above Advertising
VERIFY 3 — In the descending state the group headers order by leading-row date, not alphabeticallyGroup order is Utilities (leading row Sep 15) then Advertising (leading row Sep 5) — descending by leading-row date. Alphabetical ordering would have put Advertising first. Confirmed via accessibility read-back: "Select all expenses in Utilities" precedes "Select all expenses in Advertising".
Date descending: Utilities group header (leading Sep 15) above Advertising group header (Sep 5)
VERIFY 4 — A second press gives true chronological order and does NOT re-apply the RBR hoistArrow is ascending again. Utilities group: Sep 1 Alpha Store, Sep 10 Charlie Depot (RBR), Sep 15 Delta Supply — the RBR row is in its true date position (2nd), unlike the first-open state where it was hoisted to position 1. Advertising group: Sep 5 Bravo Mart.
Date ascending after second header press: Sep 1, Sep 10 (RBR/on hold), Sep 15 - RBR no longer hoisted
VERIFY 5 — In the ascending state the group headers order by earliest date, not alphabeticallyGroup order is Utilities (earliest Sep 1) then Advertising (Sep 5). Alphabetical would have put Advertising first, and this is a real change from the first-open state where Advertising led. Confirmed via accessibility read-back.
Date ascending: Utilities group header (earliest Sep 1) above Advertising group header (Sep 5)
VERIFY 6 — RHP prev/next order matches the visible table row order under the active sortWith the table ascending (visible order: Alpha Store Sep 1, Charlie Depot Sep 10, Delta Supply Sep 15, Bravo Mart Sep 5), opening the first row put the RHP on $100.00 Alpha Store with prev disabled. Three next presses walked exactly $75.00 Charlie Depot → $60.00 Delta Supply → $250.00 Bravo Mart, where next became disabled — matching the visible order including the cross-group transition into Advertising. One prev press returned to $60.00 Delta Supply.
RHP on the last row (250.00 Bravo Mart) with next disabled, alongside the ascending table
VERIFY 7 — No JS console errors during the flowNot executable with the available tooling: agent-device logs --platform web returns UNSUPPORTED_OPERATION, and browser console capture is out of scope for the agent-device web surface. Indirect signal: a network dump over the sort/navigation window shows only 200s apart from transient POST /api/AuthenticatePusher 403s that retried successfully (dev-environment Pusher re-auth, unrelated to sorting), and no error banner or crash surfaced. Marked ❌ because the assertion could not be run, not because errors were observed.

One wording nuance on step 1: with Group by: Category active, the RBR-first hoist lifts the flagged expense to the top of its group, not to the top of the whole table. If the intent is "first row of the report, period", that is not what happens today; if it is "first within its group", it works.


view run · view recording

… the grouping memo, add the RHP arrow regression test
@MelvinBot

Copy link
Copy Markdown
Contributor Author

All six addressed and pushed in 8a42888. Replies are on each thread; summary here.

# Comment What changed
1 🟠 hasUserSortedTransactions never reset Both hasUserSortedTransactions and sortConfig now reset when reportID changes. sortConfig leaked on main too, so resetting only the flag would have left the next report with the previous rows' order under alphabetical group headers.
2 🟠 comparator memo re-introduces report/policy Comparator folded into the groupedTransactions memo, so it inherits the narrowed dependency list instead of adding a second memo.
3 🟡 leadingA && leadingB guard undocumented Commented as defensive, with why a group is never empty.
4 🟠 missing RHP arrow regression test Added to tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx.
5 🟡 misleading test name Renamed to flattens back into the rendered row order, plus a note at the assertion that ['4','3','2','1'] would mean grouping is bypassed.
6 🟡 re-derived SortOrder Imports the real SortOrder; type-fest is out of the file.

Two things worth your attention:

On #2, the performance regression as described was already happening before this PR. sortedTransactions depends on the full report and policy at MoneyRequestReportTransactionList.tsx:440, and groupedTransactions depends on it through resolvedTransactions — so an incoming comment re-buckets and re-totals on main as well. The change is a simplification, not a fix for that. It's also why the narrowed list can't go stale: getTransactionSortValue reads more of the report than currency (getReportCustomColumnValue for the submitter and deal-number columns), and only the upstream dependency keeps that correct.

On #1, the reset runs during render rather than in the [reportID] effect — setState inside an effect is an error under react-hooks/set-state-in-effect in npm run lint. Same outcome, and the new report never paints with the old sort.

The new regression test and how it was validated

tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx:349 renders the real MoneyRequestReportTransactionList (the unified list is stubbed so its controller is readable), presses the Date header through the real onSortPress, and asserts the seeded carousel equals the rendered rows. visualOrderTransactionIDs, the real setActiveTransactionIDs, and the Onyx key the arrows read are all in the path.

The fixture puts the newest expense in the alphabetically-last category, so the three candidate orders are distinct: first open 1, 3, 2, 4, Date ↓ grouped 4, 2, 3, 1, Date ↓ flattened 4, 3, 2, 1.

Mutation-checked: it fails when compareLeadingTransactions is forced to undefined, and when visualOrderTransactionIDs is made to flatten sortedTransactions. A second test at line 372 covers #1 and fails without the reset.

Checks run locally on this branch
  • npm test for MoneyRequestReportTransactionsNavigationTest, ReportLayoutUtilsTest, MoneyRequestReportTransactionListActiveTransactionIDsTest, MoneyRequestReportGroupHeaderTest, MoneyRequestReportViewTest, MoneyRequestReportActionsListRejectModalTest, MoneyRequestReportTransactionItemRejectErrorTest — 80 tests, all passed
  • npm run typecheck — passed
  • npm run lint on the four changed files — passed
  • npm run spell-changed on the four changed files — 0 issues
  • npm run react-compiler-compliance-check check <MoneyRequestReportTransactionList.tsx> — still the one pre-existing "missing/extra memoization dependencies" error, verified identical with the change stashed

npm run lint-changed couldn't run here (git merge-base fails against origin/main in this checkout), so ESLint was run on the changed files directly with the same config.


view run

Co-authored-by: Kevin Brian Bader <ikevin127@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Fixed and pushed as 5b86f61. The only real failure was Oxfmt check, and it now passes.

tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx had never been run through the formatter. Two spots: the @components/Search/SearchContext type import sat in its own block below the react-native-onyx imports instead of in the alphabetized group above, and the useResponsiveLayout mock's return object was a single long line that oxfmt wraps one property per line. Running npm run fmt produced exactly those changes and nothing else — tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx:8 and :71-78.

No source or test logic changed.

The one check still red is Check independent approval, which just needs a human reviewer — nothing for me to fix there.

Verification
  • npm test -- tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx — 6/6 passed
  • npm run typecheck — passed
  • npx eslint tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx — clean (npm run lint-changed couldn't run here: the checkout is shallow, so git merge-base against origin/main fails)
  • npm run fmt re-run over the full repo afterwards — no remaining diff, matching what CI asserts

I didn't run browser verification for this one, since a formatting-only change to a test file has no UI behavior to check.


view run

Comment on lines +31 to +38
/** The rendered list items the transaction list hands the unified list, in render order. */
type CapturedListItem = {type: 'section-header'} | {type: 'transaction'; transaction: Transaction};

/** The slice of the transaction list's controller the tests below read. */
type CapturedController = {
tableColumnHeader: React.ReactElement<{onSortPress: (sortBy: string, sortOrder: SortOrder) => void}> | null;
transactionListItems: CapturedListItem[];
};

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.

@MelvinBot 🟡 New finding in the UI test: two locally-declared types duplicate exported production types.

type CapturedListItem = {type: 'section-header'} | {type: 'transaction'; transaction: Transaction};
type CapturedController = {tableColumnHeader: ...; transactionListItems: CapturedListItem[]};

MoneyRequestReportTransactionList.tsx:938 already exports both TransactionListItemData and MoneyRequestReportTransactionListController, and MoneyRequestReportUnifiedList.tsx:17 imports exactly those.

The test also narrows transaction to Transaction where production uses TransactionWithOptionalHighlight (also exported), so the local copy will silently drift if the production shape changes.

Should be Pick<MoneyRequestReportTransactionListController, 'tableColumnHeader' | 'transactionListItems'>.

…der test

Co-authored-by: Kevin Brian Bader <ikevin127@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Fixed in 8130594 — the test now reuses the exported production types instead of declaring its own.

  • CapturedListItem is gone. CapturedController is now Pick<MoneyRequestReportTransactionListController, 'tableColumnHeader' | 'transactionListItems'>, so transactionListItems carries the real TransactionListItemData (including TransactionWithOptionalHighlight, groupKey, and group) and can't drift.
  • The transaction narrowing in getRenderedTransactionIDs is now Extract<TransactionListItemData, {type: 'transaction'}> rather than a hand-written member.

One deviation from the literal suggestion: the controller types tableColumnHeader as a plain React.ReactElement | null, with no props parameter, so Pick<> alone leaves .props.onSortPress as any and the @typescript-eslint/no-unsafe-* rules reject reading it. pressDateHeader narrows just that one prop via React.isValidElement<{onSortPress?: ...}>() — a real runtime guard, not a cast, and confined to the single call site that drives the header rather than duplicated across a whole controller type.

Checks
  • npm test -- tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx — 6 passed
  • npm run typecheck — passed
  • npx eslint tests/ui/MoneyRequestReportTransactionsNavigationTest.tsx — clean (lint-changed itself can't run here: git merge-base against origin/main fails on this shallow checkout)
  • npm run spell-changed — 0 issues

view run

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

Labels

Melvin-Test-Android Melvin-Test-Web Triggers Melvin to run the testing steps of the PR on web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants