Skip to content

[$175] [Sentry: APP-JQB] FlashList "right operand of 'in' is not an object" in participant selector #103776

Open

Description

@mountiny

Sentry

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

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

  • Users (total since first seen): 80
  • Users (last 30d, releases 9.5.5 to 9.5.7 only): 26
  • Events (30d window): 84
  • First seen: 2026-08-02
  • Last seen: ongoing
  • Platform: iOS hybrid app
  • App version(s): 9.5.5
  • Affected route(s): Dynamic money request step: participants (create expense flow)
  • Mechanism: generic (unhandled TypeError during render)

Stack trace (top frames, first-party only)

FlashList
useRecyclerViewManager
processDataUpdate
getDataKey
_temp2 (BaseSelectionListWithSections)

Suspected cause

getDataKey in the FlashList data processing runs the 'in' operator on a value that is not an object. The data comes from BaseSelectionListWithSections inside MoneyRequestParticipantsSelector.

Reproduction

Unknown. See the events linked from the Sentry issue.

Related

  • Sibling Sentry IDs: none
  • Prior GH issues: none found
Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @thelullabyy

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 - @thelullabyy (External)

  4. changed the title [-][Sentry: APP-JQB] FlashList "right operand of 'in' is not an object" in participant selector[/-] [+][$175] [Sentry: APP-JQB] FlashList "right operand of 'in' is not an object" in participant selector[/+] on Oct 12, 2026
  5. melvin-bot commented on Oct 12, 2026

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

    @mountiny
    ContributorAuthor

    (Vit's agent) @thelullabyy, there is no proposal on this issue yet. Can you look at the Sentry issue and tell us whether we should open it for contributors as it stands, or ask for expert contributor help because it needs deeper FlashList knowledge?

  7. taieddie commented on Oct 12, 2026

    @taieddie

    Contributor details
    Your Expensify account email: taieddie8@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~01af851c9a7f771775

    Proposal

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

    A runtime TypeError ("right operand of 'in' is not an object") crashes the application on iOS during render in the participant selector when creating an expense.

    What is the root cause of that problem?

    The crash occurs in src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx at line 367:
    keyExtractor={(item) => ('flatListKey' in item ? item.flatListKey : item.keyForList)}

    When FlashList performs list recycling, layout measurement, or data updates in useRecyclerViewManager.processDataUpdate -> getDataKey, keyExtractor can be invoked with an item that is undefined or not an object (for example, during rapid search filter transitions, list resets, or if section.data contains uninitialized entries).

    In JavaScript, evaluating the in operator on a non-object ('flatListKey' in undefined) immediately throws TypeError: right operand of 'in' is not an object in JavaScriptCore on iOS.

    By contrast, the flat list variant in src/components/SelectionList/BaseSelectionList.tsx (line 523) safely guards the item with optional chaining and provides a fallback to the index string:
    keyExtractor={(item, index) => item?.keyForList ?? index.toString()}

    In BaseSelectionListWithSections.tsx, keyExtractor lacks both the object type guard and the index fallback.

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

    1. In src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx:
      Update keyExtractor to accept (item, index) and guard against null or non-object values before evaluating property access:
    • Check if item is a valid object (item && typeof item === 'object').
    • If valid, return item.flatListKey when present (or item.keyForList).
    • Fall back safely to index.toString() if item is null, undefined, or missing key properties.
    1. In src/components/SelectionList/hooks/useFlattenedSections.ts:
      In the inner loop iterating over section.data, add an explicit guard (if (!item || typeof item !== 'object') continue;) to prevent null or undefined entries from being pushed into flattenedData during dynamic search calculations.

    2. In automated tests:
      Add unit test coverage in tests/unit/BaseSelectionListSectionsTest.tsx asserting that:

    • keyExtractor safely returns a deterministic key when given a standard row item with flatListKey.
    • keyExtractor safely returns keyForList when given a section header.
    • keyExtractor returns index.toString() without throwing when invoked with undefined or null.

    What alternative solutions did you explore? (Optional)

    I explored filtering nullish items only inside ParticipantSearchResults.tsx. However, patching individual consumer screens is fragile because any screen utilizing SelectionListWithSections (such as workspace members, approver picker, or currency picker) could trigger the same crash if FlashList invokes keyExtractor during internal data recycling. Hardening keyExtractor directly in BaseSelectionListWithSections and useFlattenedSections resolves the issue at the framework layer for all consumers, matching the defensive pattern already used in BaseSelectionList.tsx.

  8. melvin-bot commented on Oct 12, 2026

    @melvin-bot

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

  9. MelvinBot commented on Oct 12, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    FlashList asks for a key for an item index that is past the end of the current list. Our keyExtractor then runs 'flatListKey' in undefined, which throws.

    This happens because the participants list is wrapped in <Activity>. When the screen blurs, Activity hides the list and runs its effect cleanups, but the list stays mounted:

    1. MoneyRequestParticipantsSelector.tsx:114 wraps the list in <Activity mode={isActivityVisible ? 'visible' : 'hidden'}>. Deferring & deprioritizing participant search render added it. That PR merged on 2026-07-27, six days before this crash was first seen.
    2. When the list is hidden, FlashList's effect cleanup calls recyclerViewManager.dispose(). A disposed manager skips updateScrollOffset, so it never refreshes its engaged indices (the item indices it keeps rendered).
    3. The hidden list still re-renders when the sections change. If the list gets shorter, processDataUpdate reuses the old engaged indices. Then getDataKey calls keyExtractor(data[index]) with data[index] === undefined.
    4. Our keyExtractor at BaseSelectionListWithSections.tsx:367 has no guard, so it throws.

    The flattened data itself is fine. useFlattenedSections.ts only pushes object literals. The undefined comes from an out-of-range index, not from a bad entry.

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

    Make the keyExtractor in BaseSelectionListWithSections null-safe. When item is missing, fall back to index.toString().

    This matches existing code:

    • BaseSelectionList.tsx:523 already does item?.keyForList ?? index.toString(). Its comment notes that FlashList can pass a stale index after the dataset shrinks.
    • getItemType in the same file (line 36) already uses item?.type.

    FlashList recycles the stale slots on its next sync, after the list becomes visible again.

    What alternative solutions did you explore? (Optional)

    Add a @shopify/flash-list patch (017) so FlashList never uses stale indices. Either skip processDataUpdate while the manager is disposed, or ignore engaged indices >= data.length in updateRenderStack. This would protect every FlashList that renders under Activity. It is the same kind of bug that patch 016 fixed on another code path, in Guard FlashList ViewHolderCollection render against stale layout index (web crash). We can do this as well as the app-side guard. It is not a replacement for the guard.

    I didn't choose filtering falsy items in useFlattenedSections. The data never contains falsy items, so the filter wouldn't fix the crash.

    Confidence and open questions
    • Mechanism: high confidence. In FlashList 2.3.2 plus our patches, this is the only path where processDataUpdate uses an out-of-range index. React disconnects passive effects only for Activity/Offscreen, not for Suspense or react-freeze. That explains why the crash started only after the Activity change.
    • Trigger PR: medium-high confidence. The timing and the code match. I didn't confirm that the first-seen release contains it, because Sentry wasn't reachable from this run.
    • Not reproduced on a device. The exact data change while the list is hidden is unknown. The likely candidates are a selection or participants reset after picking someone, or an Onyx or search refresh during the blur or refocus transition. Sentry breadcrumbs around navigation would confirm.

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


    view run

  10. janiya2 commented on Oct 12, 2026

    @janiya2

    Proposal

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

    [Sentry: APP-JQB] Unhandled TypeError: right operand of 'in' is not an object (or Cannot use 'in' operator to search for 'flatListKey' in undefined) in MoneyRequestParticipantsSelector / BaseSelectionListWithSections on iOS/Android.


    What is the root cause of that problem?

    The crash occurs because FlashList evaluates keyExtractor with an out-of-range/stale index (data[index] === undefined) when the list dataset shrinks or updates while blurred under <Activity>:

    1. The <Activity> Blur Lifecycle:
      In src/pages/iou/request/MoneyRequestParticipantsSelector.tsx (line 114), the participants list is wrapped in:
      <Activity mode={isActivityVisible ? 'visible' : 'hidden'}>
      When the user selects a participant or navigates away, the screen blurs, triggering the Activity hidden state. Under Activity hidden mode, FlashList's cleanup calls recyclerViewManager.dispose(). When disposed, updateScrollOffset is suspended, leaving the cached "engaged indices" (the range of item indices FlashList keeps rendered) untouched.

    2. Dataset Shrinking and Stale Engaged Indices:
      When sections or participant results update (e.g. selection resets or search query clear), the new flattenedData length is shorter than the previously cached engaged indices. During processDataUpdate, FlashList iterates over these stale engaged indices and calls getDataKey(index), which executes:
      keyExtractor(data[index], index)
      Because index >= data.length, data[index] evaluates to undefined.

    3. Missing Guard in BaseSelectionListWithSections.tsx:
      In src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx (line 367):

      keyExtractor={(item) => ('flatListKey' in item ? item.flatListKey : item.keyForList)}

      Unlike its flat counterpart in BaseSelectionList.tsx (lines 522-523), which explicitly documents this behavior:

      // FlashList can pass a stale index here while its render stack syncs after the dataset shrinks, so item can be undefined.
      keyExtractor={(item, index) => item?.keyForList ?? index.toString()}

      BaseSelectionListWithSections.tsx neither accepts the second index argument nor guards against item being undefined. Evaluating 'flatListKey' in undefined throws:
      TypeError: right operand of 'in' is not an object.

    (Note: The data emitted by useFlattenedSections.ts is completely valid and only contains constructed object literals; the undefined is strictly produced by FlashList's out-of-bounds indexing during data updates).


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

    In src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx (line 367), update keyExtractor to accept (item, index) and guard against null/undefined:

    -   keyExtractor={(item) => ('flatListKey' in item ? item.flatListKey : item.keyForList)}
    +   // FlashList can pass a stale index here while its render stack syncs after the dataset shrinks, so item can be undefined.
    +   keyExtractor={(item, index) => (item && typeof item === 'object' && 'flatListKey' in item ? item.flatListKey : (item?.keyForList ?? index.toString()))}

    This brings BaseSelectionListWithSections into full parity with the defensive pattern already established in BaseSelectionList.tsx:523 and getItemType (BaseSelectionListWithSections.tsx:36). When FlashList syncs on the subsequent pass, it safely recycles the stale slots without crashing.


    What alternative solutions did you explore? (Optional)

    1. Filtering in useFlattenedSections.ts:
      Filtering falsy items inside useFlattenedSections.ts does not solve the crash because useFlattenedSections.ts never produces undefined items—the undefined value originates inside FlashList when indexing beyond flattenedData.length.
    2. Patching @shopify/flash-list:
      While a patch could be added to ignore out-of-bounds indices in RecyclerViewManager.ts, handling the fallback in keyExtractor adheres to Expensify's standard architecture in BaseSelectionList.tsx and prevents requiring patch maintenance across future FlashList upgrades.

    Automated Test Scenarios

    Add a unit test in tests/unit/BaseSelectionListWithSectionsTest.tsx:

    describe('BaseSelectionListWithSections keyExtractor', () => {
        // Extracted keyExtractor logic under test
        const keyExtractor = (item: any, index: number) =>
            item && typeof item === 'object' && 'flatListKey' in item ? item.flatListKey : (item?.keyForList ?? index.toString());
    
        it('does not throw and falls back to index string when item is undefined (FlashList dataset shrinking)', () => {
            expect(() => keyExtractor(undefined, 5)).not.toThrow();
            expect(keyExtractor(undefined, 5)).toBe('5');
        });
    
        it('does not throw and falls back to index string when item is null', () => {
            expect(() => keyExtractor(null, 3)).not.toThrow();
            expect(keyExtractor(null, 3)).toBe('3');
        });
    
        it('extracts flatListKey when item is a section row item', () => {
            const item = {flatListKey: '0-user_123', keyForList: 'user_123'};
            expect(keyExtractor(item, 0)).toBe('0-user_123');
        });
    
        it('extracts keyForList when item is a section header', () => {
            const item = {keyForList: 'section_header_0'};
            expect(keyExtractor(item, 0)).toBe('section_header_0');
        });
    
        it('falls back to index string when object lacks both flatListKey and keyForList', () => {
            const item = {};
            expect(keyExtractor(item, 4)).toBe('4');
        });
    });

    Contributor Details

  11. 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 \MoneyRequestParticipantsSelector, navigating or filtering participants throws an unhandled \TypeError: right operand of 'in' is not an object\ in \keyExtractor\ inside \BaseSelectionListWithSections.tsx\ (Sentry: APP-JQB).

    What is the root cause of that problem?

    In \src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx\ (line 367):
    \\ ypescript\keyExtractor={(item) => ('flatListKey' in item ? item.flatListKey : item.keyForList)}
    \
    When \MoneyRequestParticipantsSelector\ is blurred or wrapped in <Activity>, FlashList disposes its RecyclerView manager during effect cleanups without updating scroll/engaged indices. If the participant sections shrink (e.g. searching, clearing, or resetting participants), FlashList reuses engaged indices that exceed the newly shrunk dataset, passing \undefined\ to \keyExtractor(data[index]).
    Because \item\ is \undefined, 'flatListKey' in undefined\ throws \TypeError: right operand of 'in' is not an object.

    By contrast, \BaseSelectionList.tsx\ (line 523) already guards this with \item?.keyForList ?? index.toString(), noting that FlashList can query stale indices after dataset shrinkage.

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

    In \src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx:
    Make \keyExtractor\ null-safe and fall back to \index.toString():
    \\ ypescript
    keyExtractor={(item, index) => (item && 'flatListKey' in item ? item.flatListKey : item?.keyForList ?? index.toString())}
    \\

    What alternative solutions did you explore? (Optional)

    • Filtering out falsy items in \useFlattenedSections.ts: Unnecessary because the flattened sections only contain valid objects; the out-of-range index comes directly from FlashList's internal recycling stack.
    • Patching FlashList: Adding null guards directly to consumer components is idiomatic and matches the pattern already in \BaseSelectionList.tsx.

    What specific scenarios should we cover in automated tests?

    In \ ests/unit/BaseSelectionListWithSectionsTest.tsx:

    1. Verify that \keyExtractor\ returns \index.toString()\ without throwing when passed \undefined\ or null item.
    2. Verify that valid section headers and list items extract \ latListKey\ and \keyForList\ correctly.

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

  12. melvin-bot commented on Oct 12, 2026

    @melvin-bot

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

  13. camesenin commented on Oct 13, 2026

    @camesenin

    Proposal

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

    In MoneyRequestParticipantsSelector and list views using BaseSelectionListWithSections, when the list is hidden or dataset items shrink during activity state transitions, keyExtractor crashes with TypeError: Cannot use 'in' operator to search for 'flatListKey' in undefined due to stale index evaluation on item without verifying that item is a valid object.

    What is the root cause of that problem?

    In BaseSelectionListWithSections.tsx:367, keyExtractor evaluates:

    'flatListKey' in item

    without verifying that item is a valid object.

    When MoneyRequestParticipantsSelector blurs, it wraps the list in <Activity mode={isActivityVisible ? 'visible' : 'hidden'}>. Hiding the component invokes recyclerViewManager.dispose() in FlashList, which halts scroll offset recalculations.
    If the underlying flattened sections shrink while the list remains mounted and hidden, FlashList's processDataUpdate attempts to reconcile layout using stale engaged indices that exceed the new dataset bounds. FlashList then calls keyExtractor(data[index], index) with data[index] === undefined.

    Evaluating 'flatListKey' in undefined throws:
    TypeError: Cannot use 'in' operator to search for 'flatListKey' in undefined.

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

    1. In src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx:367, guard item in keyExtractor before checking 'flatListKey':
    if (!item || typeof item !== 'object') {
        return index.toString();
    }

    If item is undefined (stale out-of-range index), safely fall back to index.toString().
    This mirrors the existing pattern already implemented in src/components/SelectionList/BaseSelectionList.tsx:523 (item?.keyForList ?? index.toString()).
    2. Apply the same defensive guard to getItemType in BaseSelectionListWithSections.tsx:36 to ensure stale indices don't trigger subsequent runtime errors.
    3. Add a unit test in tests/unit/BaseSelectionListWithSectionsTest.tsx:

    • Pass an undefined item to keyExtractor and assert that it returns index.toString() without throwing.

    What alternative solutions did you explore? (Optional)

    • Unmounting the list instead of toggling Activity visibility: Rejected because keeping the list mounted preserves scroll positions and layout caches for fast tab switching. Defensive guarding in keyExtractor matches the sibling BaseSelectionList pattern directly.

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

  14. melvin-bot commented on Oct 13, 2026

    @melvin-bot

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

  15. melvin-bot commented on Oct 15, 2026

    @melvin-bot

    @thelullabyy Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!

  16. thelullabyy commented on Oct 18, 2026

    @thelullabyy
    Contributor

    Looking....

  17. thelullabyy commented on Oct 18, 2026

    @thelullabyy
    Contributor

    @MelvinBot Sentry event 63145b22151c41f483b7b0277d1fb79a confirms a fatal error in the processDataUpdate → sync → getDataKey → _temp2 path, with BaseSelectionListWithSectionsImpl in the component stack.
    Its breadcrumbs show distance-map → participants → restricted-action screen → back to participants → touch the current user’s row → exception.
    Could you use that sequence to investigate and validate your null-safe key-extractor fix? Please add regression coverage for missing items and for hiding the list, shrinking its data, and revealing it again. The Activity/stale-index explanation is consistent with the code, but the Sentry event does not directly record those internal states, so we should confirm them before treating that mechanism as established.

  18. MelvinBot commented on Oct 18, 2026

    @MelvinBot
    Contributor

    Validation of the null-safe keyExtractor fix

    Result: A Jest test reproduces the crash when the list is hidden, its data shrinks, and it is shown again. The error and stack match the Sentry event. The null-safe keyExtractor fixes it. The breadcrumbs fit this mechanism, but one step is still unproven: I couldn't find which data change happens while the list is hidden.

    Confirmed in Jest (real FlashList 2.3.2 with our patches; FlashList is not mocked):

    • Hide the list, shrink it from 30 rows to 2, then show it: unfixed code throws 'flatListKey' in undefined. The stack is keyExtractor ← getDataKey ← RenderStackManager.sync ← updateRenderStack ← processDataUpdate. It throws during the hidden render, not when the list is shown again.
    • The same shrink with the list visible does not throw. So the crash only happens when Activity hides the list.
    • With the fix, nothing throws. After the list is shown again, only rows 0 and 1 render, with no leftover rows.

    How the breadcrumbs map to code:

    1. Participants → restricted-action screen: Tapping a workspace chat that is restricted for billing opens RESTRICTED_ACTION (ParticipantSearchResults.tsx:496-512). The participants screen stays mounted, its Activity becomes hidden, and FlashList calls dispose().
    2. Back to participants: The Activity becomes visible after the transition ends (MoneyRequestParticipantsSelector.tsx:97-114). FlashList's effect then calls restoreIfNeeded(). The round trip leaves no stale state, so the restricted-action step may be incidental.
    3. Tap the current user's row: trackExpense updates the draft. The self-DM row moves to the top section, and the now-empty "Personal" header is dropped. The flattened data gets 1 item shorter (ParticipantSearchResults.tsx:414-425, useFlattenedSections.ts:46-56). Navigation then pops or replaces the participants screen (Navigation.ts:603-617), which hides the Activity again.

    What's still unproven: The Onyx update from the tap probably re-renders before navigation, which waits for requestAnimationFrame. If so, that shrink happens while the list is visible, and the visible path is safe. The crash would then need a second shrinking render after the screen loses focus, while the participants screen is leaving. I didn't find that update in code.

    The breadcrumb timestamps would settle this. If a navigation breadcrumb comes between the tap and the exception, the mechanism fits. I couldn't open the Sentry event because the Sentry connection failed in this run. I also didn't replay the sequence in the app. It needs a workspace past its billing grace period, which a test account can't easily get.

    The fix doesn't depend on that answer. Any out-of-range index now gets an index-based key. renderItem and getItemType already handle a missing item.

    Fix diff (BaseSelectionListWithSections.tsx)
    @@ -36,6 +36,18 @@ function getItemType(...)
         return item?.type ?? CONST.SECTION_LIST_ITEM_TYPE.ROW;
     }
     
    +/**
    + * FlashList can pass a stale index here while its render stack syncs after the dataset shrinks, so item can be undefined.
    + * This happens e.g. when the data shrinks while the list is hidden by <Activity>, because the hidden list's disposed
    + * RecyclerViewManager keeps its old engaged indices.
    + */
    +function keyExtractor(item: FlattenedItem<ListItem> | undefined, index: number): string {
    +    if (!item) {
    +        return index.toString();
    +    }
    +    return 'flatListKey' in item ? item.flatListKey : item.keyForList;
    +}
    +
    @@ -373,7 +385,7 @@
    -                    keyExtractor={(item) => ('flatListKey' in item ? item.flatListKey : item.keyForList)}
    +                    keyExtractor={keyExtractor}
    Regression tests (tests/unit/BaseSelectionListWithSectionsActivityTest.tsx)

    The file has 3 cases:

    • Hide, shrink, show: Shrinks the list from 30 rows to 2 while it is hidden, then shows it. Expects no error and only rows ['0','1']. Unfixed code fails this case.
    • Shrink while visible: The control case. It passes even without the fix.
    • Missing item: keyExtractor(undefined, 5) returns '5'. Unfixed code fails this case.
    import {render, screen} from '@testing-library/react-native';
    
    import OnyxListItemProvider from '@components/OnyxListItemProvider';
    import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem';
    import BaseSelectionListWithSections from '@components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections';
    import type {FlattenedItem, ListItem} from '@components/SelectionList/SelectionListWithSections/types';
    
    import type Navigation from '@libs/Navigation/Navigation';
    
    import CONST from '@src/CONST';
    
    import type * as FlashListModule from '@shopify/flash-list';
    
    import * as NativeNavigation from '@react-navigation/native';
    import React, {Activity} from 'react';
    
    import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';
    
    // FlashList's RecyclerViewManager looks items up by index (data[index]), so an index past the end of the data yields undefined
    type MaybeFlattenedItem = FlattenedItem<ListItem> | undefined;
    
    // Records the keyExtractor that BaseSelectionListWithSections hands to FlashList so it can be unit tested,
    // while still rendering the real FlashList so its render stack / engaged-indices logic runs.
    let mockLastKeyExtractor: FlashListModule.FlashListProps<MaybeFlattenedItem>['keyExtractor'];
    
    jest.mock('@shopify/flash-list', () => {
        const ReactLocal = jest.requireActual<typeof React>('react');
        const actual = jest.requireActual<typeof FlashListModule>('@shopify/flash-list');
    
        function RecordingFlashList(props: FlashListModule.FlashListProps<MaybeFlattenedItem>) {
            mockLastKeyExtractor = props.keyExtractor;
            return ReactLocal.createElement(actual.FlashList<MaybeFlattenedItem>, props);
        }
    
        return {...actual, FlashList: RecordingFlashList};
    });
    
    jest.mock('@src/components/ConfirmedRoute.tsx');
    jest.mock('@react-navigation/native', () => {
        const actualNav = jest.requireActual<typeof Navigation>('@react-navigation/native');
        return {
            ...actualNav,
            useIsFocused: jest.fn(),
            useFocusEffect: jest.fn(),
            useNavigation: jest.fn(() => ({
                isFocused: jest.fn(() => true),
            })),
        };
    });
    
    jest.mock('@hooks/useLocalize', () =>
        jest.fn(() => ({
            translate: jest.fn((key: string) => key),
            numberFormat: jest.fn((num: number) => num.toString()),
        })),
    );
    
    jest.mock('@hooks/useKeyboardShortcut', () => jest.fn());
    
    const LONG_LIST_LENGTH = 30;
    const SHORT_LIST_LENGTH = 2;
    
    function buildItems(length: number) {
        return Array.from({length}, (_, index) => ({
            text: `Item ${index}`,
            keyForList: `${index}`,
            isSelected: false,
        }));
    }
    
    type CaptureErrorBoundaryProps = {
        children: React.ReactNode;
        onError: (error: Error) => void;
    };
    
    type CaptureErrorBoundaryState = {
        error: Error | null;
    };
    
    /** Captures render errors (including ones thrown while the list is hidden by Activity) so the test can assert on them */
    class CaptureErrorBoundary extends React.Component<CaptureErrorBoundaryProps, CaptureErrorBoundaryState> {
        constructor(props: CaptureErrorBoundaryProps) {
            super(props);
            this.state = {error: null};
        }
    
        static getDerivedStateFromError(error: Error): CaptureErrorBoundaryState {
            return {error};
        }
    
        componentDidCatch(error: Error) {
            this.props.onError(error);
        }
    
        render() {
            if (this.state.error) {
                return null;
            }
            return this.props.children;
        }
    }
    
    type ListUnderTestProps = {
        itemCount: number;
        isVisible: boolean;
        onError: (error: Error) => void;
    };
    
    /** Mirrors MoneyRequestParticipantsSelector, which wraps its sections list in <Activity> */
    function ListUnderTest({itemCount, isVisible, onError}: ListUnderTestProps) {
        return (
            <CaptureErrorBoundary onError={onError}>
                <Activity mode={isVisible ? 'visible' : 'hidden'}>
                    <OnyxListItemProvider>
                        <BaseSelectionListWithSections
                            sections={[{data: buildItems(itemCount), sectionIndex: 0}]}
                            ListItem={SingleSelectListItem}
                            onSelectRow={() => {}}
                        />
                    </OnyxListItemProvider>
                </Activity>
            </CaptureErrorBoundary>
        );
    }
    
    function getRenderedRowKeys() {
        return screen
            .queryAllByTestId(new RegExp(`^${CONST.BASE_LIST_ITEM_TEST_ID}\\d+$`), {includeHiddenElements: true})
            .map((element) => String(element.props.testID).replace(CONST.BASE_LIST_ITEM_TEST_ID, ''))
            .sort((a, b) => Number(a) - Number(b));
    }
    
    describe('BaseSelectionListWithSections inside Activity', () => {
        beforeEach(() => {
            jest.mocked(NativeNavigation.useIsFocused).mockReturnValue(true);
            mockLastKeyExtractor = undefined;
        });
    
        it('does not crash when the data shrinks while the list is hidden by Activity and is then revealed', async () => {
            const errors: Error[] = [];
            const onError = (error: Error) => errors.push(error);
    
            // Given a visible list with enough rows that FlashList engages indices well past the future data length
            const {rerender} = render(
                <ListUnderTest
                    itemCount={LONG_LIST_LENGTH}
                    isVisible
                    onError={onError}
                />,
            );
            await waitForBatchedUpdatesWithAct();
            expect(getRenderedRowKeys().length).toBeGreaterThan(SHORT_LIST_LENGTH);
    
            // When Activity hides the list, React runs FlashList's effect cleanups, which dispose its RecyclerViewManager
            rerender(
                <ListUnderTest
                    itemCount={LONG_LIST_LENGTH}
                    isVisible={false}
                    onError={onError}
                />,
            );
            expect(errors).toEqual([]);
    
            // When the data shrinks while hidden, the disposed manager can't recompute its engaged indices, so FlashList
            // syncs its render stack with the stale ones and asks keyExtractor for keys of indices past the end of the data
            rerender(
                <ListUnderTest
                    itemCount={SHORT_LIST_LENGTH}
                    isVisible={false}
                    onError={onError}
                />,
            );
    
            // Then the hidden re-render does not throw
            expect(errors).toEqual([]);
    
            // When the list is revealed again
            rerender(
                <ListUnderTest
                    itemCount={SHORT_LIST_LENGTH}
                    isVisible
                    onError={onError}
                />,
            );
            await waitForBatchedUpdatesWithAct();
    
            // Then nothing throws and only the remaining rows are rendered, with no stale rows left behind
            expect(errors).toEqual([]);
            expect(getRenderedRowKeys()).toEqual(['0', '1']);
        });
    
        it('does not crash when the data shrinks while the list stays visible', async () => {
            const errors: Error[] = [];
            const onError = (error: Error) => errors.push(error);
    
            // Given a visible list with enough rows that FlashList engages indices well past the future data length
            const {rerender} = render(
                <ListUnderTest
                    itemCount={LONG_LIST_LENGTH}
                    isVisible
                    onError={onError}
                />,
            );
            await waitForBatchedUpdatesWithAct();
            expect(getRenderedRowKeys().length).toBeGreaterThan(SHORT_LIST_LENGTH);
    
            // When the data shrinks without Activity ever hiding the list
            rerender(
                <ListUnderTest
                    itemCount={SHORT_LIST_LENGTH}
                    isVisible
                    onError={onError}
                />,
            );
            await waitForBatchedUpdatesWithAct();
    
            // Then FlashList recomputes its engaged indices and renders only the remaining rows
            expect(errors).toEqual([]);
            expect(getRenderedRowKeys()).toEqual(['0', '1']);
        });
    
        it('falls back to the index when FlashList passes a missing item to keyExtractor', () => {
            // Given the list has rendered and handed its keyExtractor to FlashList
            render(
                <ListUnderTest
                    itemCount={SHORT_LIST_LENGTH}
                    isVisible
                    onError={() => {}}
                />,
            );
            expect(mockLastKeyExtractor).toBeDefined();
    
            // When FlashList asks for the key of an index past the end of the shrunk data, where data[index] is undefined
            // Then the key falls back to the index instead of throwing
            expect(mockLastKeyExtractor?.(undefined, 5)).toBe('5');
        });
    });

    These checks pass with the fix: the new tests, the existing BaseSelectionListSectionsTest.tsx (16/16 across both files), lint, npm run typecheck, and the React Compiler check.

    Why the hidden list uses stale indices (FlashList 2.3.2)
    • When Activity hides the list, it runs effect cleanups, and FlashList calls recyclerViewManager.dispose() (useRecyclerViewManager.ts). When the list is shown again, the same effect calls restoreIfNeeded().
    • processDataUpdate runs in useMemo during render, so it runs even while the list is hidden (RecyclerViewManager.ts:302-315). A disposed manager skips updateScrollOffset (L111-L127). The render stack then syncs with the old engaged indices, and RenderStackManager.sync calls getDataKey for each one without checking the data length.
    • Patches 002, 009, and 016 don't touch this path.

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


    view run

  19. thelullabyy commented on Oct 18, 2026

    @thelullabyy
    Contributor

    @MelvinBot Please implement your proposal

  20. MelvinBot commented on Oct 18, 2026

    @MelvinBot
    Contributor

    🤖 Draft PR created: Make the sections list keyExtractor handle a missing item
    Please mark it as "Ready for review" or ask me to do that for you when it is ready for review.

    The PR makes the keyExtractor in BaseSelectionListWithSections fall back to the index when item is missing. It also adds regression tests for a missing item, for shrinking the data while visible, and for hiding the list, shrinking its data, and showing it again. With the fix reverted, the new tests fail with the same error as Sentry. You still need to fill in the Tests and QA sections.


    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.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions