Skip to content

[$175] [Sentry: APP-KT0] Search bulk actions crash reading undefined search results data #103775

Open

Description

@mountiny

Sentry

https://expensify.sentry.io/issues/APP-KT0

Impact (snapshot at filing, 2026-10-12)

  • Users (total since first seen): 96
  • Users (last 30d, releases 9.5.5 to 9.5.7 only): 88
  • Events (30d window): 98
  • First seen: 2026-09-08
  • Last seen: ongoing
  • Platform: web
  • App version(s): 9.5.6
  • Affected route(s): /search
  • Mechanism: generic (unhandled TypeError during render)

Stack trace (top frames, first-party only)

src/hooks/useSearchBulkActions.ts:2920  .map(({reportID}) => currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`])
src/hooks/useSearchBulkActions.ts:2053  useMemo
SearchBulkActionsButton.tsx:106

Suspected cause

currentSearchResults?.data is undefined while selected reports exist, and the code indexes into it without optional chaining on data. Likely fix: use currentSearchResults?.data?.[...].

Reproduction

Unknown. See the events linked from the Sentry issue.

Related

  • Possible sibling (not verified): APP-C89, same 'reading report_' pattern on /search/view/:hash
  • Prior GH issues: none found
Upwork Automation - Do Not Edit

Activity

  1. self-assigned this
    on Oct 12, 2026
  2. added
    ExternalAdded to denote the issue can be worked on by a contributor
    BugSomething is broken. Auto assigns a BugZero manager.
    on Oct 12, 2026
  3. melvin-bot commented on Oct 12, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @ikevin127 (External)

  4. changed the title [-][Sentry: APP-KT0] Search bulk actions crash reading undefined search results data[/-] [+][$175] [Sentry: APP-KT0] Search bulk actions crash reading undefined search results data[/+] on Oct 12, 2026
  5. melvin-bot commented on Oct 12, 2026

    @melvin-bot
  6. haiquywork commented on Oct 12, 2026

    @haiquywork

    Proposal

    What is the root cause of that problem?

    In useSearchBulkActions, the selectedMergeReports computation indexes currentSearchResults?.data[...]. The ?. only guards currentSearchResults. When currentSearchResults exists but data is undefined (for example while the snapshot is loading or being cleared, with selected reports still in state), undefined[...] throws. Every other access to this object in the file uses currentSearchResults?.data?.[...], so this is the only unguarded one.

    const selectedMergeReports = areAllMatchingItemsSelected
    ? []
    : selectedReports
    .map(({reportID}) => currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`])

    What changes do you think we should make in order to solve the problem?

    • In useSearchBulkActions.ts (around line 2924), change the lookup to optional chaining on data as well:
    .map(({reportID}) => currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`])
    • When data is missing, the lookup gives undefined, so selectedMergeReports ends up shorter than selectedReports and the merge option is not shown. This is the correct behaviour.
    • Add a unit test in the useSearchBulkActions tests, "does not crash when search results have no data": render the hook with selected reports and currentSearchResults set to {search: {...}} with no data. Expect no throw and no merge option.
    • Grep the file for any other .data[ access without ?.. I found only this one.

    What alternative solutions did you explore? (Optional)

    • Skip the merge computation when currentSearchResults?.data is undefined. This is more code for the same result.
    • Fall back to searchResults (the last non-empty ref). This changes behaviour, and the code elsewhere deliberately uses the current results.
  7. MelvinBot commented on Oct 12, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    The merge-reports check in useSearchBulkActions reads currentSearchResults?.data[...]. The ?. guards only currentSearchResults, not data. This is the only unguarded data access in the file.

    data can be missing at runtime. When a search starts for a new hash, its optimistic update creates the snapshot with only search and errors. data stays missing until the response arrives, or for good if the request fails. Report selection lives in a separate provider, so selected reports can still exist on that render. The lookup then reads from undefined and throws.

    TypeScript does not catch this because SearchResults.data is typed as required.

    Code references

    I didn't reproduce this in the app. The root cause comes from the stack trace and the code path.

    What changes do you think we should make in order to solve the problem?

    1. In useSearchBulkActions.ts, add optional chaining on data in the selectedMergeReports lookup, so it reads currentSearchResults?.data?.[...].
    2. When data is missing, every lookup returns undefined and gets filtered out. The lengths then don't match, so the hook hides the Merge option until results load. That is the right behavior.
    3. Add a unit test. Given selected reports and currentSearchResults with search but no data, the hook should not throw and should not offer Merge.

    What alternative solutions did you explore? (Optional)

    Make data optional in the SearchResults type so TypeScript flags unguarded accesses like this one. This prevents repeats, such as the possible sibling APP-C89. But it touches many call sites, so it fits a separate cleanup better than this fix.

    Note: The existing proposal from haiquywork describes the same root cause and fix.


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/103775) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.


    view run

  8. mountiny commented on Oct 12, 2026

    @mountiny
    ContributorAuthor

    (Vit's agent) @ikevin127, can you review the proposal from MelvinBot on this issue? Please tell us which of these fits:

    1. This is ready to open for contributors as it stands.
    2. We should ask for expert contributor help, because it needs deeper native, navigation or FlashList knowledge.
    3. The proposal needs changes first.
  9. janiya2 commented on Oct 12, 2026

    @janiya2

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    [Sentry: APP-KT0] Unhandled TypeError: Cannot read properties of undefined (reading 'report_<reportID>') occurring in useSearchBulkActions on the /search page.


    What is the root cause of that problem?

    In src/hooks/useSearchBulkActions.ts (around lines 2921-2925):

    const selectedMergeReports = areAllMatchingItemsSelected
        ? []
        : selectedReports
              .map(({reportID}) => currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`])
              .filter((report) => !!report?.reportID && report.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);

    The optional chaining operator ?. only guards currentSearchResults, but fails to guard .data.

    When search results are loading, being refreshed, or right after an optimistic clear where currentSearchResults has the structure {search: {...}} without data, currentSearchResults?.data evaluates to undefined. If reports are currently selected in state, indexing into undefined[${ONYXKEYS.COLLECTION.REPORT}${reportID}] throws:

    TypeError: Cannot read properties of undefined (reading 'report_...')
    

    Every other lookup in useSearchBulkActions.ts (e.g. lines 753 and 2012) correctly uses currentSearchResults?.data?.[...]. Line 2924 is the only unguarded access in the entire file.


    What changes do you think we should make in order to solve the problem?

    In src/hooks/useSearchBulkActions.ts (line 2924), add optional chaining to .data:

      const selectedMergeReports = areAllMatchingItemsSelected
          ? []
          : selectedReports
    -           .map(({reportID}) => currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`])
    +           .map(({reportID}) => currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`])
                .filter((report) => !!report?.reportID && report.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);

    When data is missing or undefined during loading states, every report lookup evaluates safely to undefined and is discarded by .filter(...). selectedMergeReports.length will not equal selectedReports.length, which gracefully suppresses the "Merge Reports" bulk action until search data has fully loaded.


    What alternative solutions did you explore? (Optional)

    • Adding an explicit if (!currentSearchResults?.data) return [] guard: Using optional chaining ?. is cleaner, aligns with existing conventions in the file (lines 753, 2012), and minimizes boilerplate.

    Automated Test Scenarios

    Add a unit test in tests/unit/useSearchBulkActionsTest.ts:

    describe('useSearchBulkActions', () => {
        it('does not throw and omits merge bulk action when currentSearchResults.data is undefined', () => {
            const {result} = renderHook(() =>
                useSearchBulkActions({
                    currentSearchResults: {search: {isLoading: true}} as any,
                    selectedReports: [{reportID: '12345'} as any],
                }),
            );
    
            expect(() => result.current.bulkActions).not.toThrow();
            const hasMergeOption = result.current.bulkActions?.some(
                (action) => action.value === CONST.SEARCH.BULK_ACTION_TYPES.MERGE_REPORTS,
            );
            expect(hasMergeOption).toBe(false);
        });
    
        it('includes merge bulk action when currentSearchResults.data contains all selected reports', () => {
            // Verify merge bulk action is offered when data is populated and merge conditions pass
        });
    });

    Contributor Details

  10. jloa-dev commented on Oct 12, 2026

    @jloa-dev

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    In /search, performing or resetting bulk actions throws an unhandled \TypeError: Cannot read properties of undefined (reading 'report_')\ during render in \useSearchBulkActions.ts\ (Sentry: APP-KT0).

    What is the root cause of that problem?

    In \src/hooks/useSearchBulkActions.ts\ (line 2920 / line 2936):
    \\ ypescript
    const selectedMergeReports = areAllMatchingItemsSelected
    ? []
    : selectedReports
    .map(({reportID}) => currentSearchResults?.data[\\])
    .filter((report) => !!report?.reportID && report.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
    \
    While all other calls across \useSearchBulkActions.ts\ safely access \currentSearchResults?.data?.[\\]\ (e.g. lines 752, 847, 1995, 2008, 2022, 2035, 2200, 2221), this mapping omits optional chaining on \currentSearchResults?.data.
    When transitioning between search views or when the search query snapshot is reset while reports remain selected, \currentSearchResults?.data\ is \undefined, and indexing into it throws immediately during \useMemo\ render.

    What changes do you think we should make in order to solve the problem?

    In \src/hooks/useSearchBulkActions.ts:
    Add optional chaining to \currentSearchResults?.data:
    \\ ypescript
    const selectedMergeReports = areAllMatchingItemsSelected
    ? []
    : selectedReports
    .map(({reportID}) => currentSearchResults?.data?.[\\])
    .filter((report) => !!report?.reportID && report.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
    \\

    What alternative solutions did you explore? (Optional)

    • Guarding \currentSearchResults?.data\ at the top of the option builder block: while functional, using ?.\ directly preserves idiomatic consistency with all other report snapshot readers in this hook.

    What specific scenarios should we cover in automated tests?

    In \ ests/unit/useSearchBulkActionsTest.ts:

    1. Verify that building search bulk actions with selected reports when \currentSearchResults.data\ is \undefined\ or empty does not throw and returns empty merge options.

    Contributor details
    Your Expensify account email: jloa.dev@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~0163955a2ba22190aa

  11. melvin-bot commented on Oct 12, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  12. ikevin127 commented on Oct 13, 2026

    @ikevin127
    Contributor

    Sentry findings (APP-KT0):

    • The crash line is confirmed. It's the selectedMergeReports lookup, now at src/hooks/useSearchBulkActions.ts:2936 on main. It has 107 events from 98 users.
    • It goes back further than the issue says. Events appear in every release from 9.4.73 to 9.5.6, not just 9.5.5 to 9.5.7. About 95% are on /search, with a few on /search/r/:id, /search/view/:hash and /search/edit-multiple-transactions.
    • APP-C89 is a real sibling. It has 345 events from 71 users and the same unguarded currentSearchResults?.data[...] read at MoneyRequestView.tsx:232.
    • The same pattern exists elsewhere on main: useMergeTransactions.ts:59,85-88 and SearchReportsMergeReports.tsx:226.

    Reliable repro (web, dev or staging, where window.Onyx is available):

    1. Open Spend > Reports with the report view (type:expense-report) and tick one or more reports so the bulk actions button appears.
    2. In the console, remove data from the current snapshot and keep only search:
    const snaps = await Onyx.get('snapshot_');
    const [key, snap] = Object.entries(snaps).find(([, s]) => s?.search?.type === 'expense-report' && s?.data);
    Onyx.set(key, {search: {...snap.search, isLoading: true, state: 'loading'}});
    1. The page crashes straight away with TypeError: Cannot read properties of undefined (reading 'report_') from SearchBulkActionsButton → useSearchBulkActions. The list itself keeps rendering because the page falls back to the last results that had data. Your selection is never cleared, which is exactly the state the hook trips over.

    With the fix applied, the same steps leave the page working and the Merge option simply doesn't show.


    Detailed proposals review

    [!note]
    Only Proposal 1 (MelvinBot) is under consideration, since Help Wanted hasn't been applied yet. No proposals were edited (includesCreatedEdit: false across the board), so priority isn't in question.

    Proposal 1 (MelvinBot), created 2026-10-12T14:37:38Z

    Root cause: ✅ correct, verified on main.

    • The optimistic MERGE in src/libs/actions/Search.ts only writes search and errors. For a hash with no snapshot yet, data stays undefined until the response lands, and stays that way if the request fails (failureData doesn't touch data).
    • SearchResultsProvider passes the raw snapshot through as currentSearchResults.
    • The early return in the headerButtonsOptions/dropdownButtonsOptions useMemo only checks selectedTransactionsKeys.length and hash, not data. So a lingering selection runs straight into the unguarded lookup.
    • Every clearSelectedTransactions() in Search/index.tsx runs from an effect, so there's always at least one render where stale selectedReports sit next to a snapshot with no data.
    • SearchResults.data is typed as required, which is why TS never flagged it.

    Sentry matches: 107 events / 98 users, about 95% on /search, every release from 9.4.73 through 9.5.6.

    Solution: ✅ correct. currentSearchResults?.data?.[...] matches every other read in the hook (L755, L2001, L2014, ...). The filter then drops the undefined entries, the length check fails, and Merge stays hidden until results load. That's the right behavior.

    🟡 The scope is too narrow for the root cause. APP-C89 (345 events / 71 users) is a confirmed sibling with the same pattern at src/components/ReportActionItem/MoneyRequestView.tsx:232:

    const parentReport = parentReportFromOnyx ?? currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`];

    The same unguarded read also exists in src/hooks/useMergeTransactions.ts:59,85-88 and src/pages/Search/SearchReportsMergeReports.tsx:226. Each one is the same one-character fix with the same cause, and patching only one leaves the next crash already queued. I'd fold them into this PR.

    🟢 Test: tests/unit/hooks/useSearchBulkActionsTest.ts already exists, so the new case should go there instead of a new file. Given a selected report and a currentSearchResults with search but no data, assert that the hook doesn't throw and doesn't offer MERGE_REPORTS.

    🟢 Agree that making data optional on SearchResults is the real long-term guard, and that it belongs in a separate cleanup.

    @MelvinBot's proposal LGTM, with one addition: also fix the sibling currentSearchResults?.data[...] reads listed above.

    (Vit's agent) @ikevin127, can you review the proposal from MelvinBot on this issue? Please tell us which of these fits:

    1. This is ready to open for contributors as it stands.
    2. We should ask for expert contributor help, because it needs deeper native, navigation or FlashList knowledge.
    3. The proposal needs changes first.

    On the options asked: 1, ready to go as it stands, no expert help needed.

    🎀👀🎀 C+ reviewed

  13. melvin-bot commented on Oct 13, 2026

    @melvin-bot

    Current assignee @mountiny is eligible for the choreEngineerContributorManagement assigner, not assigning anyone new.

  14. camesenin commented on Oct 13, 2026

    @camesenin

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    In search results view, performing bulk actions or transitioning search state while reports are selected crashes the app with TypeError: Cannot read properties of undefined (reading 'report_<reportID>') because useSearchBulkActions.ts unguardedly accesses currentSearchResults.data[...] during transient states where data is undefined.

    What is the root cause of that problem?

    In src/hooks/useSearchBulkActions.ts (around line 2921-2925), the selectedMergeReports calculation looks up reports from the active search results:

    .map(({reportID}) => currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`])

    The optional chaining ?. only guards currentSearchResults, but does not guard .data.

    During search transitions or when initiating a search query, src/libs/actions/Search.ts:803-826 optimistically writes a snapshot containing search and errors, leaving data undefined until the network request completes. Because selected reports are held in a separate state provider (SearchSelectionProvider), selected items can persist across renders while currentSearchResults.data is undefined.

    Evaluating currentSearchResults?.data[...] when data is undefined immediately throws:
    TypeError: Cannot read properties of undefined (reading 'report_<reportID>').

    Every other reference in useSearchBulkActions.ts safely uses currentSearchResults?.data?.[...] (e.g., lines 753 and 2012). Line 2921 was the sole unguarded access.

    What changes do you think we should make in order to solve the problem?

    1. In src/hooks/useSearchBulkActions.ts, add optional chaining to .data:
    .map(({reportID}) => currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`])
    1. When data is undefined, the lookup resolves to undefined and is discarded by the subsequent filter. This prevents any length match with selectedReports and safely disables the Merge action until valid search data is available.
    2. Add a unit test in tests/unit/useSearchBulkActionsTest.ts:
      • Initialize the hook with non-empty selected reports and currentSearchResults where data is undefined.
      • Verify that the hook renders without throwing and that canMerge remains false.

    What alternative solutions did you explore? (Optional)

    • Updating SearchResults.data type to optional (data?: ...): While desirable for long-term type safety, modifying the core Onyx type affects dozens of call sites across the codebase. Adding surgical optional chaining solves the runtime crash directly with zero regression risk.

    Contributor details
    Your Expensify account email: camesenin@gmail.com
    Upwork Profile link: https://www.upwork.com/freelancers/~0170d6b02e9440e604

  15. melvin-bot commented on Oct 13, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  16. mountiny commented on Oct 13, 2026

    @mountiny
    ContributorAuthor

    @ikevin127 feel free to go ahead

  17. ikevin127 commented on Oct 13, 2026

    @ikevin127
    Contributor

    @MelvinBot Implement this with the extended scope mentioned here in "Detailed proposals review", to open a draft PR.

  18. MelvinBot commented on Oct 13, 2026

    @MelvinBot
    Contributor

    🤖 Draft PR created: Guard search snapshot data reads that crash while a search is loading
    Please mark it as "Ready for review" or ask me to do that for you when it is ready for review.

    feat: Add count and spend total selectors to the Spend footer already guards the main crash line in useSearchBulkActions (APP-KT0), and it merged today. So this PR covers the extended scope:

    • It guards the sibling reads in MoneyRequestView.tsx (APP-C89), useMergeTransactions.ts (getTransaction) and SearchReportsMergeReports.tsx.
    • It adds the regression test to tests/unit/hooks/useSearchBulkActionsTest.ts.

    useMergeTransactions.ts lines 85-88 were left unchanged. They're already inside an if (... currentSearchResults?.data) block, so they can't throw.

    Typecheck passes for the changed files. I couldn't run Jest, ESLint, formatting or the React Compiler check in this environment, so CI is the first run for those.


    view run

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

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.ExternalAdded to denote the issue can be worked on by a contributorReviewingHas a PR in reviewWeeklyKSv2

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions