[NO QA] Fix isDeletedAction crash when originalMessage is a string#90201
Conversation
Some legacy/OldDot report actions (notably card-imported expense-update notifications) store a plain string in `message`/`originalMessage` instead of the object shape declared by `OriginalMessage<T>`. `isDeletedAction` then evaluates `'deleted' in originalMessage`, which throws `TypeError: Cannot use 'in' operator to search for 'deleted' in <string>` and crashes `ReportActionsList` whenever a report containing such an action is opened (e.g. via the Home page "Begin" CTA). Fix it in two layers: 1. Centralize the guard in `getOriginalMessage` so it returns `undefined` for non-object values, matching its declared `OriginalMessage<T> | undefined` return type. This protects every current and future `'<key>' in originalMessage` caller at once. 2. Add a defensive `typeof originalMessage === 'object'` guard at the `isDeletedAction` callsite so any future regression in `getOriginalMessage` (or a caller bypassing it) cannot reintroduce this exact crash. Add regression tests covering: - `isDeletedAction` with a string `originalMessage` - `isDeletedAction` with a non-array string `message` - `getFirstVisibleReportActionID` over a list containing a legacy string-shaped action - `getOriginalMessage` normalization for string and object inputs Fixes #90198 Co-authored-by: Cursor <cursoragent@cursor.com>
|
@eVoloshchak 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] |
|
@codex review |
There was a problem hiding this comment.
Pull request overview
Fixes a production crash in the report view caused by legacy report actions where message/originalMessage can be a plain string, which previously caused isDeletedAction to throw when using the in operator.
Changes:
- Normalize
getOriginalMessage()to returnundefinedfor non-object legacy values (e.g. strings), matching its declared TS contract. - Add a defensive
typeof originalMessage === 'object'guard inisDeletedAction()before using theinoperator. - Add unit test coverage for the crash chain (
isDeletedAction,getFirstVisibleReportActionID, andgetOriginalMessage) with legacy string-shaped actions.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/libs/ReportActionsUtils.ts |
Prevents in-operator crashes by normalizing/guarding legacy string originalMessage values. |
tests/unit/ReportActionsUtilsTest.ts |
Adds regression tests covering legacy string-shaped actions and the production crash path. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| message: [{html: 'Hello', type: 'COMMENT', text: 'Hello'}], | ||
| } as unknown as ReportAction; | ||
|
|
||
| const sorted = [createdAction, legacyExpenseUpdateAction, visibleAction]; |
There was a problem hiding this comment.
Good catch — addressed in 8a4ce5b.
The fixture is now piped through ReportActionsUtils.getSortedReportActions(actions, true), which is exactly what getSortedReportActionsForDisplay does in production (descending by timestamp, CREATED last). The test now exercises the same ordering invariant the helper relies on, and the assertion compares against legacyExpenseUpdateAction.reportActionID so a future reorder can't silently regress it.
While verifying I also noticed the CREATED fixture had no message, which made isDeletedAction treat it as a legacy-deleted action (message ?? [] → empty array → isLegacyDeletedComment === true). That's not how production CREATED actions look, so I gave it a non-empty message to keep the fixture realistic.
|
Codex Review: Didn't find any major issues. Bravo. ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
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". |
|
🚧 @mountiny has triggered a test Expensify/App build. You can view the workflow run here. |
|
I can repro the issue in supportal for this user so I will test on the adhoc |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
Pipe the test fixture through `getSortedReportActions(actions, true)` to match the production sort order used by `getSortedReportActionsForDisplay` (descending by timestamp, CREATED last). This makes the test exercise the same ordering invariant `getFirstVisibleReportActionID` relies on, and prevents the array name `sorted` from being misleading. Also give the CREATED fixture a non-empty `message` so it is not treated as a legacy-deleted action by `isDeletedAction` (production CREATED actions always carry a message). The expected return value is now derived from the fixture variable so a future reorder cannot silently regress the assertion. Co-authored-by: Cursor <cursoragent@cursor.com>
…on-string-originalMessage
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppAndroid: mWeb ChromeiOS: HybridAppiOS: mWeb SafariMacOS: Chrome / SafariMacOS: Desktop |
|
🚀 Deployed to staging by https://github.com/mountiny in version: 9.3.70-0 🚀
Bundle Size Analysis (Sentry): |
|
No help site changes are required. This PR is a purely internal bug fix — it adds a defensive type guard in |
Explanation of Change
Some legacy/OldDot report actions (notably card-imported expense-update notifications such as "The <date> <merchant> expense has been updated with official data from an imported card") store a plain string in
message/originalMessageinstead of the object shape declared byOriginalMessage<T>.isDeletedActionthen evaluates'deleted' in originalMessage, which throwsTypeError: Cannot use 'in' operator to search for 'deleted' in <string>. The error propagates up throughArray.filter→getFirstVisibleReportActionID→useMemoinReportActionsList, crashing the report view whenever such a report is opened (e.g. via the Home page Begin CTA on a "Submit X report" task).The fix has two complementary layers:
getOriginalMessage— returnundefinedfor non-object values so the runtime matches the declaredOriginalMessage<T> | undefinedreturn type. This protects every current and future'<key>' in originalMessagecaller at once (there are 15+ such call sites across the repo).typeof === 'object'guard at theisDeletedActioncall site — belt‑and‑suspenders, so any future regression ingetOriginalMessage(or a caller bypassing it) cannot reintroduce this exact crash. This matches the existing pattern in sibling helpers in the same file (getWhisperedTo,isResolvedActionableWhisper,hasReasoning).Includes regression unit tests for:
isDeletedActionwith a stringoriginalMessageisDeletedActionwith a non-array stringmessagegetFirstVisibleReportActionIDover a list containing a legacy string-shaped action (the exact crash chain from production)getOriginalMessagenormalization for string and object inputsBoth new tests fail on
mainagainst the pre-fix code, locking in the regression.Fixed Issues
$ #90198
PROPOSAL: #90198 (proposal is the issue body itself — Sentry-discovered crash, no external proposal flow)
Tests
tests/unit/ReportActionsUtilsTest.ts, the new cases underdescribe('isDeletedAction', …),describe('getFirstVisibleReportActionID', …)anddescribe('getOriginalMessage', …)exercise the regression. Run:Offline tests
This is a defensive type guard in a pure function and does not depend on network state. Repro requires only the existing on-device Onyx data; no API calls are involved on the crash path. Toggling offline mode while opening the affected report should yield identical behavior (no crash, list renders).
QA Steps
// [No QA] — Mechanical type guard / fix on a code path that already handles the falsy case correctly. There is no UI or behavioral change for non-affected report actions, and affected report actions simply stop crashing the list. Covered by unit tests.
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
N/A — defensive type guard, no UI changes. Affected report actions simply stop crashing the report list.
Android: Native
N/A — no UI changes.
Android: mWeb Chrome
N/A — no UI changes.
iOS: Native
N/A — no UI changes.
iOS: mWeb Safari
N/A — no UI changes.
MacOS: Chrome / Safari
N/A — no UI changes.
Made with Cursor