Skip to content

[Due for payment 2026-05-20] [$250] TypeError: Cannot use 'in' operator to search for 'deleted' in <string> — crash on /home when opening a report from "Begin" #90198

@mountiny

Description

@mountiny

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

  1. Open the Home page (/home).
  2. 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).
  3. 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 getFirstVisibleReportActionIDuseMemo 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 OwnerCurrent Issue Owner: @eVoloshchak

Metadata

Metadata

Assignees

Labels

BugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions