Bug Report
Error
TypeError: Cannot use 'in' operator to search for 'deleted' in <expense-update notification string>
at isDeletedAction (src/libs/ReportActionsUtils.ts:224)
at Array.filter (anonymous)
at getFirstVisibleReportActionID (src/libs/ReportActionsUtils.ts:1663)
at useMemo (...)
at ReportActionsList (...)
Sentry
This is the same fingerprint as the previously closed APP-6E0. It is still receiving fresh events on recent releases — please reopen the Sentry group / verify it has not been auto-resolved by inactivity.
Action performed
- Open the Home page (
/home).
- From the "For you" section, click the Begin button on a "Submit X report" task that opens an expense report whose history contains a legacy "expense has been updated with official data from an imported card" report action (common on accounts with imported card transactions).
- The report opens in the RHP and the report list crashes with the
TypeError above.
Expected result
The report list renders and the existing "expense updated with official data from an imported card" notification action is treated as not-deleted (its real shape).
Actual result
isDeletedAction throws TypeError: Cannot use 'in' operator to search for 'deleted' in <string>, which propagates up through getFirstVisibleReportActionID → useMemo in ReportActionsList and crashes the report view.
Platforms
- All web/mWeb (Sentry events come from web). Native is not directly observed but the same code path runs there.
Reproducible in staging? / production?
- Production: Yes (live Sentry events).
- Staging: Likely yes when there is at least one legacy "expense updated by imported card" notification action in the report history.
Root Cause Analysis
The crash happens inside isDeletedAction in src/libs/ReportActionsUtils.ts, specifically the last clause on line 224:
return isLegacyDeletedComment
|| !!message.at(0)?.deleted
|| (!!originalMessage && 'deleted' in originalMessage && !!originalMessage?.deleted);
originalMessage is obtained from getOriginalMessage(reportAction):
function getOriginalMessage<T extends ReportActionName>(reportAction: OnyxInputOrEntry<ReportAction<T>>): OriginalMessage<T> | undefined {
if (!Array.isArray(reportAction?.message)) {
return reportAction?.message ?? reportAction?.originalMessage;
}
return reportAction?.originalMessage;
}
For some legacy/OldDot expense-update report actions, the backend stores the user-facing notification as a plain string (e.g. "The <date> <merchant> expense has been updated with official data from an imported card") — either directly in originalMessage, or in message when message is not an array, in which case getOriginalMessage falls through to it.
The !!originalMessage truthiness check passes for any non-empty string, so execution reaches 'deleted' in originalMessage. The JS in operator requires its right operand to be an object; with a string it throws the TypeError shown above.
The TypeScript signature of getOriginalMessage is OriginalMessage<T> | undefined, so callers see an object-shaped type and the runtime mismatch is invisible at compile time. The team is already aware this happens at runtime — there is an explicit Log.info('Original message is not an object for reportAction: ', ...) in getWhisperedTo of the same file, and several sibling helpers (getWhisperedTo, isResolvedActionableWhisper, hasReasoning) already wrap their '<key>' in originalMessage usage with a typeof originalMessage === 'object' guard. isDeletedAction is the outlier.
Why this triggers on Home → "Begin": clicking "Begin" navigates to the report and mounts ReportActionsList, which builds a useMemo that calls getFirstVisibleReportActionID(sortedReportActions). That helper does sortedReportActions.filter(action => !isDeletedAction(action) || …). A single legacy expense-update action anywhere in the report's history is enough to throw and crash the entire list.
This is the same defect previously triaged as #83937, which was closed without a fix; the targeted PR #83940 was closed without merging.
Proposed Fix (robust, centralized)
A site-by-site typeof guard (the approach in PR #83940) only covers the call sites we currently see. There are ~15+ '<key>' in originalMessage usages in the codebase (see git grep "in originalMessage"), so the same bug class can resurface elsewhere any time a new caller is added.
The robust fix has two layers:
1. Centralize the guard in getOriginalMessage (defense-in-depth)
Make getOriginalMessage honor its existing TypeScript contract by returning undefined for non-object values. This protects every caller at once, including future ones, and matches the declared return type OriginalMessage<T> | undefined.
In src/libs/ReportActionsUtils.ts:
function getOriginalMessage<T extends ReportActionName>(reportAction: OnyxInputOrEntry<ReportAction<T>>): OriginalMessage<T> | undefined {
const candidate = !Array.isArray(reportAction?.message)
? reportAction?.message ?? reportAction?.originalMessage
: reportAction?.originalMessage;
// Some legacy/OldDot report actions store a plain notification string in `message`/`originalMessage`.
// The TS type promises an object-shaped `OriginalMessage`, and downstream code uses the `in` operator
// (which throws on non-objects). Normalize non-object values to undefined to honor the contract.
if (candidate === null || typeof candidate !== 'object') {
return undefined;
}
return candidate as OriginalMessage<T>;
}
2. Keep an explicit guard at the isDeletedAction callsite (belt-and-suspenders)
Even with (1), defensively narrow on the in operator at the immediate crash site so that any future regression in getOriginalMessage (or any caller bypassing it) cannot reintroduce this exact crash:
return isLegacyDeletedComment
|| !!message.at(0)?.deleted
|| (!!originalMessage && typeof originalMessage === 'object' && 'deleted' in originalMessage && !!originalMessage?.deleted);
3. Apply the same guard to other unguarded in originalMessage call sites
While we're touching this, sweep the remaining unguarded '<key>' in originalMessage usages so the same crash class cannot surface in other components. Today these include (non-exhaustive):
src/libs/ReportActionsUtils.ts — lines 541, 558, 993–995, 1156, 1161
src/libs/ReportSecondaryActionUtils.ts — line 413
src/pages/Debug/Report/DebugReportActions.tsx — line 68
src/pages/inbox/report/VacationDelegateText.tsx — line 24
src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx — line 347
src/components/ReportActionItem/TripRoomPreview.tsx — line 143
src/components/ReportActionItem/TaskPreview.tsx — line 91
src/libs/actions/Transaction.ts — line 716
Once (1) is in place these are no longer crash-prone in practice (they all go through getOriginalMessage), but adding the typeof === 'object' prefix keeps the codebase self-consistent with the existing guarded sites and makes static analysis happier.
Unit test coverage
Add the following cases to tests/unit/ReportActionsUtilsTest.ts. The first three are regression tests for this bug; the last is a positive case for the centralized normalization in getOriginalMessage.
isDeletedAction — must not crash when originalMessage is a string
Inside the existing describe('isDeletedAction', ...) block (around line 1625):
it('should return false (not crash) when originalMessage is a plain string', () => {
const reportAction = {
created: '2023-09-27 10:00:00.000',
reportActionID: '123456789',
actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
// Legacy/OldDot shape: notification stored as string instead of object
originalMessage: 'The September 27, 2023 Chick-fil-A expense has been updated with official data from an imported card',
message: [
{
html: 'The September 27, 2023 Chick-fil-A expense has been updated with official data from an imported card',
type: 'COMMENT',
text: 'The September 27, 2023 Chick-fil-A expense has been updated with official data from an imported card',
},
],
} as unknown as ReportAction;
// Regression: prior to the fix this throws TypeError: Cannot use 'in' operator to search for 'deleted' in <string>
expect(() => ReportActionsUtils.isDeletedAction(reportAction)).not.toThrow();
expect(ReportActionsUtils.isDeletedAction(reportAction)).toBe(false);
});
it('should return false (not crash) when message is a non-array string and originalMessage is missing', () => {
const reportAction = {
created: '2023-09-27 10:00:00.000',
reportActionID: '123456789',
actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
// getOriginalMessage falls through to message when message is not an array;
// ensure a string `message` cannot crash isDeletedAction either.
message: 'The September 27, 2023 Chick-fil-A expense has been updated with official data from an imported card',
} as unknown as ReportAction;
expect(() => ReportActionsUtils.isDeletedAction(reportAction)).not.toThrow();
expect(ReportActionsUtils.isDeletedAction(reportAction)).toBe(false);
});
getFirstVisibleReportActionID — must not crash when the action list contains a legacy string-message action
Add a new describe:
describe('getFirstVisibleReportActionID', () => {
it('does not crash on a sortedReportActions list that contains a legacy string-originalMessage action', () => {
const created = {
created: '2023-09-27 09:00:00.000',
reportActionID: '1',
actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
} as unknown as ReportAction;
const legacyExpenseUpdate = {
created: '2023-09-27 10:00:00.000',
reportActionID: '2',
actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
originalMessage: 'The September 27, 2023 Chick-fil-A expense has been updated with official data from an imported card',
message: [{html: 'updated', type: 'COMMENT', text: 'updated'}],
} as unknown as ReportAction;
const visibleAction = {
created: '2023-09-27 11:00:00.000',
reportActionID: '3',
actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
originalMessage: {html: 'Hello', whisperedTo: []},
message: [{html: 'Hello', type: 'COMMENT', text: 'Hello'}],
} as unknown as ReportAction;
const sorted = [created, legacyExpenseUpdate, visibleAction];
// Regression: prior to the fix this throws inside Array.prototype.filter -> isDeletedAction.
expect(() => ReportActionsUtils.getFirstVisibleReportActionID(sorted)).not.toThrow();
expect(ReportActionsUtils.getFirstVisibleReportActionID(sorted)).toBe('2');
});
});
getOriginalMessage — normalizes non-object values to undefined
If a unit test file for getOriginalMessage doesn't already exist, add a describe('getOriginalMessage', ...) block in the same test file. (Note: getOriginalMessage is currently not exported. To keep the targeted fix testable directly, either export it from ReportActionsUtils.ts or test the behavior indirectly through a calling helper.)
describe('getOriginalMessage', () => {
it('returns undefined when message/originalMessage is a string (legacy shape)', () => {
const reportAction = {
actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
reportActionID: '1',
originalMessage: 'plain string from legacy backend',
} as unknown as ReportAction;
// @ts-expect-error -- export getOriginalMessage from ReportActionsUtils for this test, or assert via a dependent helper.
expect(ReportActionsUtils.getOriginalMessage(reportAction)).toBeUndefined();
});
it('returns the object when originalMessage is object-shaped', () => {
const reportAction = {
actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
reportActionID: '1',
originalMessage: {html: 'hi', whisperedTo: []},
} as unknown as ReportAction;
// @ts-expect-error -- see note above.
expect(ReportActionsUtils.getOriginalMessage(reportAction)).toEqual({html: 'hi', whisperedTo: []});
});
});
These three test groups cover both the centralized normalization and the immediate caller, and they fail today on main against the unguarded code, so they will lock in the regression.
Workaround
None at the user level — the report view is unrenderable for affected reports until the fix lands.
Upwork Automation - Do Not Edit
- Upwork Job URL: https://www.upwork.com/jobs/~022053829595538569183
- Upwork Job ID: 2053829595538569183
- Last Price Increase: 2026-05-11
Issue Owner
Current Issue Owner: @eVoloshchak
Bug Report
Error
Sentry
This is the same fingerprint as the previously closed APP-6E0. It is still receiving fresh events on recent releases — please reopen the Sentry group / verify it has not been auto-resolved by inactivity.
Action performed
/home).TypeErrorabove.Expected result
The report list renders and the existing "expense updated with official data from an imported card" notification action is treated as not-deleted (its real shape).
Actual result
isDeletedActionthrowsTypeError: Cannot use 'in' operator to search for 'deleted' in <string>, which propagates up throughgetFirstVisibleReportActionID→useMemoinReportActionsListand crashes the report view.Platforms
Reproducible in staging? / production?
Root Cause Analysis
The crash happens inside
isDeletedActioninsrc/libs/ReportActionsUtils.ts, specifically the last clause on line 224:originalMessageis obtained fromgetOriginalMessage(reportAction):For some legacy/OldDot expense-update report actions, the backend stores the user-facing notification as a plain string (e.g. "The <date> <merchant> expense has been updated with official data from an imported card") — either directly in
originalMessage, or inmessagewhenmessageis not an array, in which casegetOriginalMessagefalls through to it.The
!!originalMessagetruthiness check passes for any non-empty string, so execution reaches'deleted' in originalMessage. The JSinoperator requires its right operand to be an object; with a string it throws theTypeErrorshown above.The TypeScript signature of
getOriginalMessageisOriginalMessage<T> | undefined, so callers see an object-shaped type and the runtime mismatch is invisible at compile time. The team is already aware this happens at runtime — there is an explicitLog.info('Original message is not an object for reportAction: ', ...)ingetWhisperedToof the same file, and several sibling helpers (getWhisperedTo,isResolvedActionableWhisper,hasReasoning) already wrap their'<key>' in originalMessageusage with atypeof originalMessage === 'object'guard.isDeletedActionis the outlier.Why this triggers on Home → "Begin": clicking "Begin" navigates to the report and mounts
ReportActionsList, which builds auseMemothat callsgetFirstVisibleReportActionID(sortedReportActions). That helper doessortedReportActions.filter(action => !isDeletedAction(action) || …). A single legacy expense-update action anywhere in the report's history is enough to throw and crash the entire list.This is the same defect previously triaged as #83937, which was closed without a fix; the targeted PR #83940 was closed without merging.
Proposed Fix (robust, centralized)
A site-by-site
typeofguard (the approach in PR #83940) only covers the call sites we currently see. There are ~15+'<key>' in originalMessageusages in the codebase (seegit grep "in originalMessage"), so the same bug class can resurface elsewhere any time a new caller is added.The robust fix has two layers:
1. Centralize the guard in
getOriginalMessage(defense-in-depth)Make
getOriginalMessagehonor its existing TypeScript contract by returningundefinedfor non-object values. This protects every caller at once, including future ones, and matches the declared return typeOriginalMessage<T> | undefined.In
src/libs/ReportActionsUtils.ts:2. Keep an explicit guard at the
isDeletedActioncallsite (belt-and-suspenders)Even with (1), defensively narrow on the
inoperator at the immediate crash site so that any future regression ingetOriginalMessage(or any caller bypassing it) cannot reintroduce this exact crash:3. Apply the same guard to other unguarded
in originalMessagecall sitesWhile we're touching this, sweep the remaining unguarded
'<key>' in originalMessageusages so the same crash class cannot surface in other components. Today these include (non-exhaustive):src/libs/ReportActionsUtils.ts— lines 541, 558, 993–995, 1156, 1161src/libs/ReportSecondaryActionUtils.ts— line 413src/pages/Debug/Report/DebugReportActions.tsx— line 68src/pages/inbox/report/VacationDelegateText.tsx— line 24src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx— line 347src/components/ReportActionItem/TripRoomPreview.tsx— line 143src/components/ReportActionItem/TaskPreview.tsx— line 91src/libs/actions/Transaction.ts— line 716Once (1) is in place these are no longer crash-prone in practice (they all go through
getOriginalMessage), but adding thetypeof === 'object'prefix keeps the codebase self-consistent with the existing guarded sites and makes static analysis happier.Unit test coverage
Add the following cases to
tests/unit/ReportActionsUtilsTest.ts. The first three are regression tests for this bug; the last is a positive case for the centralized normalization ingetOriginalMessage.isDeletedAction— must not crash whenoriginalMessageis a stringInside the existing
describe('isDeletedAction', ...)block (around line 1625):getFirstVisibleReportActionID— must not crash when the action list contains a legacy string-message actionAdd a new
describe:getOriginalMessage— normalizes non-object values toundefinedIf a unit test file for
getOriginalMessagedoesn't already exist, add adescribe('getOriginalMessage', ...)block in the same test file. (Note:getOriginalMessageis currently not exported. To keep the targeted fix testable directly, either export it fromReportActionsUtils.tsor test the behavior indirectly through a calling helper.)These three test groups cover both the centralized normalization and the immediate caller, and they fail today on
mainagainst the unguarded code, so they will lock in the regression.Workaround
None at the user level — the report view is unrenderable for affected reports until the fix lands.
Upwork Automation - Do Not Edit
Issue Owner
Current Issue Owner: @eVoloshchak