Skip to content

Route Search row previews through the shared LHN alternate text - #101298

Open
sosek108 wants to merge 17 commits into
Expensify:mainfrom
callstack-internal:fix/lhn-search-preview-parity
Open

sosek108 wants to merge 17 commits into
Expensify:mainfrom
callstack-internal:fix/lhn-search-preview-parity

Conversation

@sosek108

@sosek108 sosek108 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

Makes Search row subtitles (alternateText) identical to LHN by construction.

This is the fifth and final piece of #99062, which was split into five smaller PRs. The four prerequisites are already merged.

What is left in this PR:

  • Search options now compute their preview text via the shared getReportAlternateText, the exact same entry point the LHN uses, instead of re-deriving it along a separate code path.
  • The last-action context feeding both surfaces is resolved through the shared resolveLastActionContext, so LHN and Search read the same sorted/visible report actions and the same one-transaction-thread report ID.
  • Deletes the interim helpers introduced in Fix alternate text prefix mismatch between LHN and search #98961 (getChatPreviewParts, isActionWithCustomAlternateText), which existed only to approximate LHN output from the Search side.
  • Non-React callers that cannot supply the hook-bound localeCompare / convertToDisplayStringWithoutCurrency fall back to CollatorUtils and convertToDisplayStringWithoutCurrencyForLocale, so the shared path stays reachable from everywhere.

Performance tests (against current main):

image

Fixed Issues

$ #98958
PROPOSAL:

Tests

  1. npx jest tests/unit/LhnSearchPreviewParityTest.ts tests/unit/ReportAlternateTextParityTest.ts tests/unit/OptionsListUtilsTest.tsx tests/unit/SidebarUtilsTest.ts
  2. Use a large account with a lot of reports
  3. Go through the LHN and the Search bar (CMD+K or the magnifying glass icon) and compare the subtitle of each entry. These should be the same for every entry.
  • Verify that no errors appear in the JS console

Offline tests

N/A — preview text is derived entirely from local Onyx state, so there is no network-dependent branch.

QA Steps

  1. Use a large account with a lot of reports
  2. Go through the LHN and the Search bar (CMD+K or the magnifying glass icon) and compare the subtitle of each entry. These should be the same for every entry.
  • 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
Nagranie.z.ekranu.2026-09-1.o.12.49.39.mov
iOS: mWeb Safari
MacOS: Chrome / Safari
Nagranie.z.ekranu.2026-09-16.o.10.46.07.mov

@sosek108
sosek108 requested review from a team as code owners September 16, 2026 07:56
@melvin-bot
melvin-bot Bot requested review from garrettmknight and thelullabyy and removed request for a team September 16, 2026 07:56
@melvin-bot

melvin-bot Bot commented Sep 16, 2026

Copy link
Copy Markdown

@thelullabyy Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot
melvin-bot Bot requested review from a team and removed request for a team September 16, 2026 07:56

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 58c4309455

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread tests/unit/__snapshots__/ReportAlternateTextParityTest.ts.snap Outdated
Comment thread src/libs/OptionsListUtils/index.ts Outdated
@sosek108
sosek108 marked this pull request as draft September 16, 2026 08:04
@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

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

Files with missing lines Coverage Δ
...s/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx 88.15% <ø> (ø)
.../components/Search/FilterComponents/InSelector.tsx 90.12% <100.00%> (+0.37%) ⬆️
src/components/Search/SearchAutocompleteList.tsx 88.53% <100.00%> (+0.10%) ⬆️
...rc/components/Search/SearchRouter/SearchRouter.tsx 72.88% <100.00%> (+0.30%) ⬆️
.../Search/SearchRouter/SearchRouterOptionsWarmer.tsx 96.87% <100.00%> (+0.20%) ⬆️
src/hooks/useAutocompleteSuggestions.ts 73.07% <100.00%> (+0.26%) ⬆️
src/hooks/useFilteredOptions.ts 87.09% <100.00%> (+1.91%) ⬆️
src/hooks/useSearchSelector/base.ts 87.80% <100.00%> (+0.20%) ⬆️
src/libs/ReportActionsUtils.ts 82.81% <ø> (+0.88%) ⬆️
src/libs/SidebarUtils.ts 92.83% <ø> (ø)
... and 3 more
... and 11 files with indirect coverage changes

@sosek108
sosek108 marked this pull request as ready for review September 16, 2026 08:48

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c4efa3efe7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/libs/OptionsListUtils/index.ts
@sosek108

Copy link
Copy Markdown
Contributor Author

@thelullabyy ping me if you need anything from me

@thelullabyy

Copy link
Copy Markdown
Contributor

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible 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 checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified proper code patterns were followed (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 verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • 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 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.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this 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.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

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

@garrettmknight
garrettmknight removed their request for review September 17, 2026 13:52
const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived);
// eslint-disable-next-line @typescript-eslint/no-deprecated
const resolvedOneTransactionThreadReportID = oneTransactionThreadReportID ?? deprecatedCachedOneTransactionThreadReportIDs[report.reportID];
const lastAction = getLastVisibleActionIncludingTransactionThread(report.reportID, canUserPerformWrite, undefined, visibleReportActionsData, resolvedOneTransactionThreadReportID);

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.

This passes undefined as reportActionsParam, could we add sortedActions param to resolveLastActionContext?

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.

Tried it. sortedActions can't be passed as reportActionsParam — it's Record<reportID, ReportAction[]> while the param is OnyxCollection<ReportActions> keyed reportActions_<reportID>. The way main actually used it was a .find() fast path in getChatPreviewParts, and that's the divergence this PR fixes: it scans the parent report's sorted array only, so it never sees the one-transaction thread, while LHN (OptionRowLHNData.tsx:105) has always gone through getLastVisibleActionIncludingTransactionThread with oneTransactionThreadReportID.

I implemented the fast path locally to check — LhnSearchPreviewParityTest.ts drops to 146/147, failing "one-transaction expense report whose newest action is the IOU create action" (Search loses the Alice: actor prefix LHN renders).

@thelullabyy

Copy link
Copy Markdown
Contributor

@MelvinBot Please do deep review this PR

@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 Deep review

The refactor direction is right and the mechanical work is solid — the deleted getChatPreviewParts branches all have a real counterpart in getReportAlternateText, there are no dangling references, no new circular imports, and no new Onyx collection subscriptions. Locally: 734 tests pass, npm run typecheck passes, and the repo lint pipeline is clean on all 11 changed source files. CI is green except Check independent approval, which just needs a human approver.

But the PR's core claim is not actually established, and the new cache it adds to pay for the extra work is defeated in three separate places. Details below, most important first.


1. Expense-report previews still diverge between LHN and Search — sortedActions means two different things

getLatestVisibleMoneyRequestAction is handed sortedActions?.[reportID] at src/libs/ReportAlternateTextUtils.ts:974 — but the two surfaces supply different arrays:

  • LHN never passes sortedActions, so the default at :500 uses deprecatedAllSortedReportActions[reportID], which is assigned at :296 before the getCombinedReportActions call at :310 → parent-only, still contains the IOU CREATE action.
  • Search passes the derived sortedActions, which is the combined array — and getCombinedReportActions explicitly drops IOU CREATE.

Failure scenario: a one-transaction expense report (isExpenseReport, transactionCount > 0, currency set, no scanning receipt). LHN finds the IOU action → getExpenseReportPreviewText"$20.00 for Lunch". Search finds nothing → falls to the !isCreatedAction(lastReportAction) branch at :979 with iouReportAction: undefined → a different string. This is pre-existing in main, but this is the PR claiming parity, and the divergence now sits inside the newly-shared function. LhnSearchPreviewParityTest.ts does not cover this branch.

Fix: make the two sources agree (a dedicated parent-only param for this lookup, or a raw-actions fallback when the supplied array yields no money-request action), and add a parity case for a one-transaction expense report.


2. The parity test can't fail for the failure mode this refactor introduces

tests/unit/LhnSearchPreviewParityTest.ts:244-249 — 146 of 147 cases route through expectParity, whose only content assertion is expect(lhnText).toBeTruthy(). grep "toBe('" over the file returns zero hits.

It does drive two genuinely distinct production entry points (good), but a shared-implementation refactor's characteristic failure is both surfaces going wrong together — and that passes silently. It is empirically defeatable: at ReportAlternateTextUtils.ts:569 Search's lastOriginalAction is undefined for an unseeded report, so ?? falls through to the same module-level cache the LHN reads; both then go stale together and searchText === lhnText still holds.

Related: makeAction (:59-69) never supplies originalMessage, so 77 of the 118 special-action cases collapse onto the same generic fallback. The sibling snapshot shows it — 77 identical "Fixture message html" values, plus blessed broken interpolations like "added as a member" and "The connection is broken…". So "~148 parity cases" is really ~40 distinct branches.

Fix: call toMatchSnapshot() inside expectParity (the mechanism the sibling file already uses), and give each action a minimal real originalMessage.


3. HTML-stripping coverage was removed rather than replaced

The #82036 guard is gone from OptionsListUtils (no reference to it remains), and the test that covered it was rewritten into a different scenario:

-it('should strip HTML from the last message when the last action is not ADD_COMMENT', ...
-    const report = buildDMReport({lastMessageText: '<b>test</b>', lastActionType: ...RENAMED});
-    expect(result).toBe('test');
+it('should fall back to the raw report lastMessageText when no last action is available', ...
+    expect(result).toBe('<b>test</b>');

Both tests at OptionsListUtilsTest.tsx:5756 and :5772 now seed no actions, feed '<b>test</b>', and assert '<b>test</b>' — nothing asserts stripping any more. The final fallback at ReportAlternateTextUtils.ts:1015 returns report.lastMessageText raw. Since the LHN does the same, this may be intended parity — but please say so explicitly and keep a case asserting stripped output for a non-ADD_COMMENT last action, so [Due for Payment 2026-04-09][$500] Search - HTML message is displayed different on LHN and on chat preview on search can't silently come back.


4. Performance: the shared path is heavier, and the cache that pays for it is defeated

Five separate issues — all confirmed against the head checkout

(a) resolveLastActionContext drops the fast path. ReportAlternateTextUtils.ts:429 passes undefined for reportActionsParam, so getLastVisibleAction falls back to the module global, allocates Object.values(...) and does a full O(n) scan with no early exit — twice when a transaction thread exists. The deleted getChatPreviewParts used sortedActionsForReport.find(...), which typically exits at index 0. Every call site still threads sortedActions in, but it now only reaches getLatestVisibleMoneyRequestAction. On top of that, createOption resolves the context at index.ts:568 and getAlternateText resolves the identical context again at :331 — the first result is discarded.

Note: do not just restore sortedActions[reportID].find(...) — the combined array disagrees with getLastVisibleActionIncludingTransactionThread, and the new behavior is the LHN-matching one. Better: add an optional precomputed lastAction param, or add a lastVisibleActions map to the sortedReportActions derived value, which already computes lastActions/transactionThreadIDs in the same pass.

(b) getCachedAlternateText is FIFO, not LRU. index.ts:2164-2166 returns early on a hit without re-inserting, so Map order stays first-insert order. Above ALTERNATE_TEXT_CACHE_MAX_ENTRIES = 500 each miss evicts the key requested next → 0% hit rate. The comment at :2143 says "LRU", and the sibling filteredOptionListCache in the same file gets it right at :1373-1375 with the comment "Re-inserting refreshes recency…". Reachable: createFilteredOptionList defaults maxRecentReports = 500 and InSelector passes no maxResults.

(c) useAutocompleteSuggestions omits reportAttributesDerived. SearchAutocompleteList passes it at :291; the hook it calls in the same render has zero reportAttributes references and two getSearchOptions calls (:257, :307). syncAlternateTextCache compares positionally and clears the whole cache on any mismatch, so the two paths alternate and the cache never hits in the in: flow — exactly the path the cache comment says it was added for. Secondary correctness effect: previews built there resolve room/moved-report names without derived names.

(d) getSearchOptions still defaults visibleReportActionsData = {}. index.ts:2873 — the PR replaced this with EMPTY_VISIBLE_REPORT_ACTIONS in the three downstream functions but missed this one, and since getSearchOptions always forwards a value the downstream constant never applies. InSelector doesn't pass the field, so every render allocates a fresh {} → full cache wipe.

(e) Two more threading misses. useSearchSelector's SEARCH branch omits reportAttributesDerived while its three siblings pass it (base.ts:351/397/441); the SHARE_DESTINATION branch sets showChatPreviewLine: true but omits visibleReportActionsData, which the hook already subscribes to — so that picker recomputes visibility per action instead of doing a map lookup.

(f) SearchRouterOptionsWarmer's churn-detection deps weren't updated for the two new createFilteredOptionList cache inputs (allPolicyTags, visibleReportActionsData). The latter returns a new object on every REPORT_ACTIONS write, so the warmer can build + cache a 500-report list that is invalidated by a write it never observed — burning the build and reintroducing the cold-open cost it exists to prevent.


5. Test coverage gaps worth closing

Gap Where
No non-English locale test at all. Both parity files do IntlStore.load(CONST.LOCALES.EN). The new fallbackLocaleCompare / fallbackConvertToDisplayStringWithoutCurrency resolve via IntlStore.getCurrentLocale() — under EN they are byte-identical to the hook versions, so a fallback resolving the wrong locale passes every test in this PR. OptionsListUtils/index.ts:275-285
The parity harness passes convertToDisplayStringWithoutCurrency to LHN but not to getSearchOptions, so the two surfaces aren't configured identically and the fallback is silently taken. LhnSearchPreviewParityTest.ts:165,215
Untested branches: attachment-only messages, deleted messages / deleted parent action, type: TASK reports, whispers (covered in OptionsListUtilsTest but never LHN-vs-Search). Money amounts are built in fixtures but never asserted.
Perf test: ACTIONS_PER_REPORT = 8 so the per-report scan from 4(a) isn't measured; transaction-thread IDs 300–599 point at reports that are never created, so the most expensive path walks empty collections; forcePolicyNamePreview: true + MAX_ELEMENTS = 20 mean ~20–100 previews resolve, not 300, contrary to the docblock. OptionsListChatPreview.perf-test.ts:28,36,133,164
Every parity case reuses reportID: '100', and Onyx.clear() does not purge the deprecated* module caches (the callback short-circuits on falsy actions). Doesn't trip today, but OptionsListUtilsTest.tsx already guards this exact hazard with unique per-test IDs. LhnSearchPreviewParityTest.ts:42,385
294 lines of the diff (ReportAlternateTextParityTest.ts +31/-31 and its snapshot +147/-147) are pure it() renames — every snapshot value is unchanged. Worth landing separately; as-is it's review noise in a behavior-change PR, and it means no snapshot proves the Search-side string changed.

Verified clean (so these don't get re-reviewed)

Things that looked risky but check out
  • Deleted-branch parity. Every predicate in isActionWithCustomAlternateText (renamed, task, invite/removed, card-issued, OldDot, policy-copy, moved-transaction, fraud alert, the change-log sets, all 14 CUSTOM_ALTERNATE_TEXT_ACTION_NAMES) has an explicit branch in getReportAlternateText. Nothing dropped.
  • No dangling references to getChatPreviewParts / isActionWithCustomAlternateText; the two symbols removed from ReportActionsUtils exports have no remaining external importers.
  • No new circular imports. New edges are to CollatorUtils (type-only) and CurrencyUtils (leaf).
  • SidebarUtils.ts +2/-0 is a genuine no-op for the LHN — both hoisted values are byte-identical to the inline versions, and the empty-array-is-truthy case still works.
  • lastMessageTextFromReportProp || … correctly preserves the ''-forces-recompute contract that OptionRowLHNData relies on for pending-whisper transactions. ?? would have broken it.
  • getLastActorDisplayNamePrefix() extraction is safe — both replaced blocks were identical and the closure reads only never-reassigned values.
  • fallbackLocaleCompare is exactly equivalent to the hook (getCollator(currentLocale).compare, same IntlStore seed, per-locale cached, no init required) — and it can't affect which action is picked, only getDisplayNamesWithTooltips.
  • fallbackConvertToDisplayStringWithoutCurrency uses the same locale source and the same getCurrencyDecimals / CURRENCY_LIST defaults as the context version.
  • Dropping currentUserAccountID from the visibility check is behaviour-preserving (isWhisperActionTargetedToOthers falls back to the same global), and every Search caller threads a real one anyway — "You" is not lost.
  • resolveLastActionContext matches OptionRowLHNData's own derivation line for line, apart from a draft-report fallback that can't regress.
  • All new useMemo/useCallback deps members are stablelocaleCompare/formatPhoneNumber/translate are React-Compiler-verified stable, convertToDisplayStringWithoutCurrency is a useCallback, and no inline object/function literal was added to any deps array. Deps arrays are complete.
  • No new Onyx collection subscription — the call sites already had useSortedReportActionsData(); useCardList/useWorkspaceCardList are app-wide context reads, called unconditionally before any early return.
  • Typing in the router does not recompute per keystrokeautocompleteQueryValue is debounced.
  • forcePolicyNamePreview hoisting is equivalent (the two report types are mutually exclusive).
  • Loss of the iou.expense / common.group type fallbacks for message-less reports, and archived rooms losing getReportSubtitlePrefix, are both intentional LHN parity.
  • No new non-null assertions, no removed optional chaining, no hardcoded English, no .only/.skip.

Minor / optional: currentUserLogin is sourced from currentUserEmail in two call sites and personalDetails.login in two others — the LHN uses login, so unify on that. ShareTab.tsx and InSelector.tsx pass convertToDisplayStringWithoutCurrency but both set isUsedInChatFinder: false, so it is unreachable there (the translate added in InSelector is live and is a real fix). Three of five consumers now hold a second useOnyx subscription to VISIBLE_REPORT_ACTIONS. createOption/createOptionFromReport have no way to supply cardList/localeCompare/etc — harmless today since no caller sets showChatPreviewLine, but the first one that does gets wrong strings rather than a type error.


Suggested minimum before merge: items 1, 2, 4(b), 4(c) and 4(d). 1 and 2 go to whether this PR achieves what it says; 4(b)–(d) are one-line fixes that decide whether the new cache does anything at all.

Verification run
npm test -- tests/unit/LhnSearchPreviewParityTest.ts tests/unit/ReportAlternateTextParityTest.ts \
            tests/unit/OptionsListUtilsTest.tsx tests/unit/SidebarUtilsTest.ts --ci
→ Test Suites: 4 passed  |  Tests: 734 passed  |  Snapshots: 147 passed  |  40s

npm run typecheck        → passed (all 5 tsconfigs)
npm run lint (11 files)  → clean, exit 0
gh pr checks             → 32/33 pass; only `Check independent approval` fails (needs a human approver)

Reviewed at d32a7e0. Raw npx eslint reports 20 errors on the changed files, but all 20 are grandfathered by the repo's seatbelt baseline — pre-existing, not introduced here.


Reply with @MelvinBot <your feedback> to dig into any of these further.

@sosek108

Copy link
Copy Markdown
Contributor Author
  1. Expense-report previews still diverge between LHN and Search — sortedActions means two different things

Confirmed and reproduced via unit tests. A one-transaction expense report renders differently for our both cases:

  • LHN: $12.34
  • Search: Unavailable workspace owes $12.34

This is because different argument is passed in both cases. For Search we pass sortedActions (introduced in refactor issue: #66381). The same refactor should be done for LHN but wasn't yet.

  1. if we treat LHN as source of truth and perform parity on Search side we'd reverse effort from Remove Onyx.connect() for the key: ONYXKEYS.COLLECTION.REPORT_ACTIONS in src/libs/OptionsListUtils.ts #66381
  2. if we plainly add sortedActions on LHN side it would regress to wrong wording.
  3. the correct solution is to fix sortedActions via param branch and pass this on LHN. This would serve well for Remove Onyx.connect() for the key: ONYXKEYS.COLLECTION.REPORT_ACTIONS in src/libs/OptionsListUtils.ts #66381 also. But it'd be increase the size of current PR so I'd suggest to do this in separate PR.

The question is if that exact path would be visible by user at all? I tried to reproduce this but it seems that one-transaction reports are not visible neither in LHN nor Search

Zrzut ekranu 2026-09-21 o 14 02 52 image

@sosek108

Copy link
Copy Markdown
Contributor Author
  1. The parity test can't fail for the failure mode this refactor introduces

The line 146 of 147 cases route through expectParity, whose only content assertion is expect(lhnText).toBeTruthy() is wrong because this line explicitly asserts that LHN and Search returns the same text. There are no hardcoded values because we asserts parity, not correctness.

The second part makeAction was valid suggestion. I've implemented this for LhnSearchParityTest and now we have only 1 case collapsing to the generic fallback instead of 77 cases.

@sosek108

Copy link
Copy Markdown
Contributor Author
  1. HTML-stripping coverage was removed rather than replaced

This one is 100% valid. I added additional test case to verify missing part

@sosek108

Copy link
Copy Markdown
Contributor Author
  1. Performance: the shared path is heavier, and the cache that pays for it is defeated

Proposed fixes applied.

Not done: replacing the O(n)-no-early-exit scan with a precomputed lastVisibleActions map on the sortedReportActions derived value. That's a derived-value change well outside this PR — and the scan is the same one the LHN pays, so it isn't a parity regression. If needed it should be done in external PR/issue.

@sosek108

sosek108 commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author
  1. Test coverage gaps worth closing

Fixed except for last row as this is not coverage gap.

@sosek108

Copy link
Copy Markdown
Contributor Author

@thelullabyy I've responded to each point of Melvin's review

@sosek108

Copy link
Copy Markdown
Contributor Author

@MelvinBot review

@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 Code review — the refactor is sound and all CI checks pass. Two places where "identical by construction" doesn't hold yet, both from the same root cause, plus a perf-evidence gap.

1. Search short-circuits the shared recompute with a weaker precomputed string

src/libs/OptionsListUtils/index.ts:342 passes lastMessageTextFromReport: option.lastMessageText. Because that value is non-empty, the || recompute in ReportAlternateTextUtils.ts:1255-1257 never runs — so Search uses the string createOption built, not the one getReportAlternateText would build. createOption builds it from a smaller input set, which leaks two divergences:

a. DEW "queued to submit/approve" is unreachable in Search. index.ts:565 omits reportMetadata, which is the only input to hasPendingDEWSubmit/hasPendingDEWApprove (ReportAlternateTextUtils.ts:694, :713). getReportAlternateText's own recompute does pass it (:1226). Expense report on a DYNAMICEXTERNAL policy with reportMetadata.pendingExpenseAction === 'submit' and a pending-add SUBMITTED action → LHN "You: queued to submit via custom approval workflow", Search "You: submitted".

b. Archived-reason previews can name the wrong person. createOption derives lastActorDetails as personalDetails[report.lastActorAccountID] (:562); getReportAlternateText prefers the action's actor and falls back to lastAction.person[0].text (:1239-1250). That value is interpolated into reportArchiveReasons.removedFromPolicy / accountClosed / policyDeleted (:577). Room archived via removedFromPolicy by an account absent from personalDetails (common for removed members) → LHN "Bob removed you from…", Search "Hidden removed you from…".

Neither is a regression — main computes lastActorDetails the same way — but both survive a PR whose stated goal is byte-identical output. One fix covers both: drop the precompute from the preview path (pass lastMessageTextFromReport: undefined and let getReportAlternateText own it), or pass reportMetadata and the action-derived lastActorDetails into the createOption call.

2. LHN still reads the deprecated one-transaction-thread cache while Search reads the derived value

SidebarUtils.ts:886-898 doesn't forward oneTransactionThreadReportID, so LHN falls back to deprecatedCachedOneTransactionThreadReportIDs — a cache refreshed only by REPORT_ACTIONS writes — while Search passes the derived value, which also recomputes on REPORT and NETWORK. OptionRowLHNData already has the ID it needs. A REPORT-only update (e.g. chatReportID arriving on a send-money IOU) makes the two surfaces resolve the preview from different reports until some report action changes. Cheap to close, and closing it is squarely in this PR's scope.

3. The perf numbers describe the warm cache, so they can't see the extra scan

Details

prepareReportOptionsForDisplay does not pass lastActionContext (index.ts:2251), so getAlternateText runs resolveLastActionContext a second time per row — a full Object.values() + per-action visibility scan, on top of the one createOption already did and did pass through (:563, :613). The deleted getChatPreviewParts used an early-exit find on the already-sorted array instead. Threading lastActionContext through here is a one-liner.

The new alternateTextCache masks it — but has no reset export, and OptionsListChatPreview.perf-test.ts:284-320 only clears clearFilteredOptionListCache(). Since syncAlternateTextCache's inputs keep identity across iterations, reassure's warmup populates the cache and all 10 measured runs are hits. Worth adding a clear so the cold path is measured.

Separately, the cache comment at index.ts:2086-2089 claims the preview "depends only on the report and on the Onyx snapshots guarded below", but the cached closure also reads getReportMetadata (via getReportAlternateText) and getReportOrDraftReport — neither REPORT_METADATA nor REPORT_DRAFT is in the invalidation list. A reportMetadata.pendingChatMembers change with no accompanying REPORT/REPORT_ACTIONS write leaves a stale welcome-message preview.

Smaller notes

Details
  • Dead card plumbing. cardList/workspaceCardList are threaded into the per-row preview path, but getCardIssuedMessage gates every card link on shouldRenderHTML, which is false here — so they can't change the output. They are in syncAlternateTextCache's guard, so any card-list write drops all 500 cached previews for zero text change. Consider dropping them from both surfaces rather than propagating.
  • InSelector computes transactionThreadIDs/lastActions and passes them to createOptionFromReport but not to getSearchOptions (InSelector.tsx:74-75). Harmless today (showChatPreviewLine is false there), but it reads as a bug.
  • createOption/createOptionFromReport can't forward cardList, workspaceCardList, or invoiceReceiverPolicy to getAlternateText, unlike prepareReportOptionsForDisplay. Inert today because no caller sets showChatPreviewLine: true on that path — a trap for the next caller.
  • Verified clean: dependency arrays in all six memo sites include every new value; the new POLICY_TAGS/VISIBLE_REPORT_ACTIONS subscriptions add no invalidation frequency beyond the existing useSortedReportActionsData; useCardList/useWorkspaceCardList add zero subscriptions; the fallbackLocaleCompare/fallbackConvertToDisplayStringWithoutCurrency fallbacks are behaviourally identical to the hook-bound versions and read the locale at call time; both deleted helpers are fully de-referenced repo-wide; the warmer's churn list correctly gains the two new cache inputs.

Tests

Details

LhnSearchPreviewParityTest does compare two genuinely distinct entry points (SidebarUtils.getOptionData vs createFilteredOptionListgetSearchOptions), which is the right design. Two things limit its power:

  • :444 asserts only toBeTruthy() plus equality, and every fixture shares lastMessageText: 'Fixture last message' / message.text: 'Fixture message text'. For the ~120 SIMPLE_CHAIN_ACTIONS cases with no ORIGINAL_MESSAGE_BY_ACTION entry, both surfaces can land on the same generic fallback and report green without ever entering the branch under test. expect(lhnText).not.toContain('Fixture') would make the matrix load-bearing.
  • :369 pins visibleReportActionsData empty on both sides. In production the real LHN never passes it and every Search caller does — that's one of the few inputs that genuinely differs, and the suite can't fail on it.

Uncovered branches that users see constantly: MODIFIED_EXPENSE, SUBMITTED/APPROVED/FORWARDED/REIMBURSED, HOLD/UNHOLD, CLOSED with a real archiveReason (which is exactly finding 1b), chat threads, invoice rooms with a real last action, and concierge reports. Non-English coverage is four cases, not a matrix.

Two existing assertions were weakened rather than updated:

  • OptionsListUtilsTest.tsx:5637 — the archived case changed its input from lastMessageText: 'hello' to ''. That wasn't required (with 'hello' the new code still returns 'hello'), and it dropped the "archived reports show no Name: prefix" coverage while moving the test off the precomputed path production actually takes.
  • :5885 — the sortedActions-vs-Onyx equivalence assertion was replaced with a single string check, and buildConfig lost its sortedActions parameter. Nothing now pins that the derived snapshot and the Onyx-resolved last action agree, which is the drift class this PR exists to eliminate.

Also untested: the new ~60-line alternateTextCache LRU. Its invalidation is a hand-rolled identity comparison over 22 inputs and its key excludes the preview text; a missed invalidation shows stale Search previews, the exact symptom this PR fixes. The parity suite can't reach it (fresh reportID per case).

I reviewed statically only — no app run, so none of the above is confirmed against a live large account.


Reply with @MelvinBot <your feedback> to refine this review.

…-parity-melvin-r2

Melvin r2: drop search preview precompute, derived OTT ID, remove card plumbing

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants