You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[$175] [Sentry: APP-JQB] FlashList "right operand of 'in' is not an object" in participant selector #103776
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.
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
(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?
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?
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.
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.
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.
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:
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).
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.
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.
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>:
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.
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.
Missing Guard in BaseSelectionListWithSections.tsx:
In src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx (line 367):
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)
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.
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 testconstkeyExtractor=(item: any,index: number)=>item&&typeofitem==='object'&&'flatListKey'initem ? 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',()=>{constitem={flatListKey: '0-user_123',keyForList: 'user_123'};expect(keyExtractor(item,0)).toBe('0-user_123');});it('extracts keyForList when item is a section header',()=>{constitem={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',()=>{constitem={};expect(keyExtractor(item,4)).toBe('4');});});
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:
Verify that \keyExtractor\ returns \index.toString()\ without throwing when passed \undefined\ or null item.
Verify that valid section headers and list items extract \latListKey\ and \keyForList\ correctly.
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'initem
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?
In src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx:367, guard item in keyExtractor before checking 'flatListKey':
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.
@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.
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:
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().
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.
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}
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';importOnyxListItemProviderfrom'@components/OnyxListItemProvider';importSingleSelectListItemfrom'@components/SelectionList/ListItem/SingleSelectListItem';importBaseSelectionListWithSectionsfrom'@components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections';importtype{FlattenedItem,ListItem}from'@components/SelectionList/SelectionListWithSections/types';importtypeNavigationfrom'@libs/Navigation/Navigation';importCONSTfrom'@src/CONST';importtype*asFlashListModulefrom'@shopify/flash-list';import*asNativeNavigationfrom'@react-navigation/native';importReact,{Activity}from'react';importwaitForBatchedUpdatesWithActfrom'../utils/waitForBatchedUpdatesWithAct';// FlashList's RecyclerViewManager looks items up by index (data[index]), so an index past the end of the data yields undefinedtypeMaybeFlattenedItem=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.letmockLastKeyExtractor: FlashListModule.FlashListProps<MaybeFlattenedItem>['keyExtractor'];jest.mock('@shopify/flash-list',()=>{constReactLocal=jest.requireActual<typeofReact>('react');constactual=jest.requireActual<typeofFlashListModule>('@shopify/flash-list');functionRecordingFlashList(props: FlashListModule.FlashListProps<MaybeFlattenedItem>){mockLastKeyExtractor=props.keyExtractor;returnReactLocal.createElement(actual.FlashList<MaybeFlattenedItem>,props);}return{...actual,FlashList: RecordingFlashList};});jest.mock('@src/components/ConfirmedRoute.tsx');jest.mock('@react-navigation/native',()=>{constactualNav=jest.requireActual<typeofNavigation>('@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());constLONG_LIST_LENGTH=30;constSHORT_LIST_LENGTH=2;functionbuildItems(length: number){returnArray.from({length},(_,index)=>({text: `Item ${index}`,keyForList: `${index}`,isSelected: false,}));}typeCaptureErrorBoundaryProps={children: React.ReactNode;onError: (error: Error)=>void;};typeCaptureErrorBoundaryState={error: Error|null;};/** Captures render errors (including ones thrown while the list is hidden by Activity) so the test can assert on them */classCaptureErrorBoundaryextendsReact.Component<CaptureErrorBoundaryProps,CaptureErrorBoundaryState>{constructor(props: CaptureErrorBoundaryProps){super(props);this.state={error: null};}staticgetDerivedStateFromError(error: Error): CaptureErrorBoundaryState{return{error};}componentDidCatch(error: Error){this.props.onError(error);}render(){if(this.state.error){returnnull;}returnthis.props.children;}}typeListUnderTestProps={itemCount: number;isVisible: boolean;onError: (error: Error)=>void;};/** Mirrors MoneyRequestParticipantsSelector, which wraps its sections list in <Activity> */functionListUnderTest({itemCount, isVisible, onError}: ListUnderTestProps){return(<CaptureErrorBoundaryonError={onError}><Activitymode={isVisible ? 'visible' : 'hidden'}><OnyxListItemProvider><BaseSelectionListWithSectionssections={[{data: buildItems(itemCount),sectionIndex: 0}]}ListItem={SingleSelectListItem}onSelectRow={()=>{}}/></OnyxListItemProvider></Activity></CaptureErrorBoundary>);}functiongetRenderedRowKeys(){returnscreen.queryAllByTestId(newRegExp(`^${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()=>{consterrors: Error[]=[];constonError=(error: Error)=>errors.push(error);// Given a visible list with enough rows that FlashList engages indices well past the future data lengthconst{rerender}=render(<ListUnderTestitemCount={LONG_LIST_LENGTH}isVisibleonError={onError}/>,);awaitwaitForBatchedUpdatesWithAct();expect(getRenderedRowKeys().length).toBeGreaterThan(SHORT_LIST_LENGTH);// When Activity hides the list, React runs FlashList's effect cleanups, which dispose its RecyclerViewManagerrerender(<ListUnderTestitemCount={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 datarerender(<ListUnderTestitemCount={SHORT_LIST_LENGTH}isVisible={false}onError={onError}/>,);// Then the hidden re-render does not throwexpect(errors).toEqual([]);// When the list is revealed againrerender(<ListUnderTestitemCount={SHORT_LIST_LENGTH}isVisibleonError={onError}/>,);awaitwaitForBatchedUpdatesWithAct();// Then nothing throws and only the remaining rows are rendered, with no stale rows left behindexpect(errors).toEqual([]);expect(getRenderedRowKeys()).toEqual(['0','1']);});it('does not crash when the data shrinks while the list stays visible',async()=>{consterrors: Error[]=[];constonError=(error: Error)=>errors.push(error);// Given a visible list with enough rows that FlashList engages indices well past the future data lengthconst{rerender}=render(<ListUnderTestitemCount={LONG_LIST_LENGTH}isVisibleonError={onError}/>,);awaitwaitForBatchedUpdatesWithAct();expect(getRenderedRowKeys().length).toBeGreaterThan(SHORT_LIST_LENGTH);// When the data shrinks without Activity ever hiding the listrerender(<ListUnderTestitemCount={SHORT_LIST_LENGTH}isVisibleonError={onError}/>,);awaitwaitForBatchedUpdatesWithAct();// Then FlashList recomputes its engaged indices and renders only the remaining rowsexpect(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 FlashListrender(<ListUnderTestitemCount={SHORT_LIST_LENGTH}isVisibleonError={()=>{}}/>,);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 throwingexpect(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.
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.
Activity
melvin-bot commentedon Oct 12, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @thelullabyy (
External)[-][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[/+]melvin-bot commentedon Oct 12, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022109653539631190078
mountiny commentedon Oct 12, 2026
(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?
taieddie commentedon Oct 12, 2026
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.tsxat line 367:keyExtractor={(item) => ('flatListKey' in item ? item.flatListKey : item.keyForList)}When
FlashListperforms list recycling, layout measurement, or data updates inuseRecyclerViewManager.processDataUpdate -> getDataKey,keyExtractorcan be invoked with an item that isundefinedor not an object (for example, during rapid search filter transitions, list resets, or ifsection.datacontains uninitialized entries).In JavaScript, evaluating the
inoperator on a non-object ('flatListKey' in undefined) immediately throwsTypeError: right operand of 'in' is not an objectin 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,keyExtractorlacks both the object type guard and the index fallback.What changes do you think we should make in order to solve the problem?
src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx:Update
keyExtractorto accept(item, index)and guard against null or non-object values before evaluating property access:itemis a valid object (item && typeof item === 'object').item.flatListKeywhen present (oritem.keyForList).index.toString()ifitemis null, undefined, or missing key properties.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 intoflattenedDataduring dynamic search calculations.In automated tests:
Add unit test coverage in
tests/unit/BaseSelectionListSectionsTest.tsxasserting that:keyExtractorsafely returns a deterministic key when given a standard row item withflatListKey.keyExtractorsafely returnskeyForListwhen given a section header.keyExtractorreturnsindex.toString()without throwing when invoked withundefinedornull.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 utilizingSelectionListWithSections(such as workspace members, approver picker, or currency picker) could trigger the same crash ifFlashListinvokeskeyExtractorduring internal data recycling. HardeningkeyExtractordirectly inBaseSelectionListWithSectionsanduseFlattenedSectionsresolves the issue at the framework layer for all consumers, matching the defensive pattern already used inBaseSelectionList.tsx.melvin-bot commentedon Oct 12, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
MelvinBot commentedon Oct 12, 2026
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
keyExtractorthen runs'flatListKey' in undefined, which throws.This happens because the participants list is wrapped in
<Activity>. When the screen blurs,Activityhides the list and runs its effect cleanups, but the list stays mounted:<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.recyclerViewManager.dispose(). A disposed manager skipsupdateScrollOffset, so it never refreshes its engaged indices (the item indices it keeps rendered).processDataUpdatereuses the old engaged indices. ThengetDataKeycallskeyExtractor(data[index])withdata[index] === undefined.keyExtractorat BaseSelectionListWithSections.tsx:367 has no guard, so it throws.The flattened data itself is fine. useFlattenedSections.ts only pushes object literals. The
undefinedcomes 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
keyExtractorinBaseSelectionListWithSectionsnull-safe. Whenitemis missing, fall back toindex.toString().This matches existing code:
item?.keyForList ?? index.toString(). Its comment notes that FlashList can pass a stale index after the dataset shrinks.getItemTypein the same file (line 36) already usesitem?.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-listpatch (017) so FlashList never uses stale indices. Either skipprocessDataUpdatewhile the manager is disposed, or ignore engaged indices>= data.lengthinupdateRenderStack. This would protect every FlashList that renders underActivity. 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
processDataUpdateuses an out-of-range index. React disconnects passive effects only forActivity/Offscreen, not for Suspense orreact-freeze. That explains why the crash started only after theActivitychange.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
janiya2 commentedon Oct 12, 2026
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(orCannot use 'in' operator to search for 'flatListKey' in undefined) inMoneyRequestParticipantsSelector/BaseSelectionListWithSectionson iOS/Android.What is the root cause of that problem?
The crash occurs because FlashList evaluates
keyExtractorwith an out-of-range/stale index (data[index] === undefined) when the list dataset shrinks or updates while blurred under<Activity>: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
Activityhidden state. UnderActivityhidden mode, FlashList's cleanup callsrecyclerViewManager.dispose(). When disposed,updateScrollOffsetis suspended, leaving the cached "engaged indices" (the range of item indices FlashList keeps rendered) untouched.Dataset Shrinking and Stale Engaged Indices:
When sections or participant results update (e.g. selection resets or search query clear), the new
flattenedDatalength is shorter than the previously cached engaged indices. DuringprocessDataUpdate, FlashList iterates over these stale engaged indices and callsgetDataKey(index), which executes:keyExtractor(data[index], index)Because
index >= data.length,data[index]evaluates toundefined.Missing Guard in
BaseSelectionListWithSections.tsx:In
src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx(line 367):Unlike its flat counterpart in
BaseSelectionList.tsx(lines 522-523), which explicitly documents this behavior:BaseSelectionListWithSections.tsxneither accepts the secondindexargument nor guards againstitembeingundefined. Evaluating'flatListKey' in undefinedthrows:TypeError: right operand of 'in' is not an object.(Note: The data emitted by
useFlattenedSections.tsis completely valid and only contains constructed object literals; theundefinedis 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), updatekeyExtractorto accept(item, index)and guard against null/undefined:This brings
BaseSelectionListWithSectionsinto full parity with the defensive pattern already established inBaseSelectionList.tsx:523andgetItemType(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)
useFlattenedSections.ts:Filtering falsy items inside
useFlattenedSections.tsdoes not solve the crash becauseuseFlattenedSections.tsnever produces undefined items—theundefinedvalue originates inside FlashList when indexing beyondflattenedData.length.@shopify/flash-list:While a patch could be added to ignore out-of-bounds indices in
RecyclerViewManager.ts, handling the fallback inkeyExtractoradheres to Expensify's standard architecture inBaseSelectionList.tsxand prevents requiring patch maintenance across future FlashList upgrades.Automated Test Scenarios
Add a unit test in
tests/unit/BaseSelectionListWithSectionsTest.tsx:Contributor Details
jloa-dev commentedon Oct 12, 2026
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)
What specific scenarios should we cover in automated tests?
In \ ests/unit/BaseSelectionListWithSectionsTest.tsx:
Contributor details
Your Expensify account email: jloa.dev@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~0163955a2ba22190aa
melvin-bot commentedon Oct 12, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
camesenin commentedon Oct 13, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
In
MoneyRequestParticipantsSelectorand list views usingBaseSelectionListWithSections, when the list is hidden or dataset items shrink during activity state transitions,keyExtractorcrashes withTypeError: Cannot use 'in' operator to search for 'flatListKey' in undefineddue to stale index evaluation onitemwithout verifying thatitemis a valid object.What is the root cause of that problem?
In
BaseSelectionListWithSections.tsx:367,keyExtractorevaluates:without verifying that
itemis a valid object.When
MoneyRequestParticipantsSelectorblurs, it wraps the list in<Activity mode={isActivityVisible ? 'visible' : 'hidden'}>. Hiding the component invokesrecyclerViewManager.dispose()in FlashList, which halts scroll offset recalculations.If the underlying flattened sections shrink while the list remains mounted and hidden, FlashList's
processDataUpdateattempts to reconcile layout using stale engaged indices that exceed the new dataset bounds. FlashList then callskeyExtractor(data[index], index)withdata[index] === undefined.Evaluating
'flatListKey' in undefinedthrows: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?
src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx:367, guarditeminkeyExtractorbefore checking'flatListKey':If
itemis undefined (stale out-of-range index), safely fall back toindex.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
getItemTypeinBaseSelectionListWithSections.tsx:36to ensure stale indices don't trigger subsequent runtime errors.3. Add a unit test in
tests/unit/BaseSelectionListWithSectionsTest.tsx:undefineditem tokeyExtractorand assert that it returnsindex.toString()without throwing.What alternative solutions did you explore? (Optional)
Activityvisibility: Rejected because keeping the list mounted preserves scroll positions and layout caches for fast tab switching. Defensive guarding inkeyExtractormatches the siblingBaseSelectionListpattern directly.Contributor details
Your Expensify account email: camesenin@gmail.com
Upwork Profile link: https://www.upwork.com/freelancers/~0170d6b02e9440e604
melvin-bot commentedon Oct 13, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
melvin-bot commentedon Oct 15, 2026
@thelullabyy Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!
thelullabyy commentedon Oct 18, 2026
Looking....
thelullabyy commentedon Oct 18, 2026
@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.
MelvinBot commentedon Oct 18, 2026
Validation of the null-safe
keyExtractorfixResult: 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
keyExtractorfixes 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):
'flatListKey' in undefined. The stack iskeyExtractor←getDataKey←RenderStackManager.sync←updateRenderStack←processDataUpdate. It throws during the hidden render, not when the list is shown again.Activityhides the list.How the breadcrumbs map to code:
RESTRICTED_ACTION(ParticipantSearchResults.tsx:496-512). The participants screen stays mounted, itsActivitybecomes hidden, and FlashList callsdispose().Activitybecomes visible after the transition ends (MoneyRequestParticipantsSelector.tsx:97-114). FlashList's effect then callsrestoreIfNeeded(). The round trip leaves no stale state, so the restricted-action step may be incidental.trackExpenseupdates 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 theActivityagain.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.
renderItemandgetItemTypealready handle a missing item.Fix diff (
BaseSelectionListWithSections.tsx)Regression tests (
tests/unit/BaseSelectionListWithSectionsActivityTest.tsx)The file has 3 cases:
['0','1']. Unfixed code fails this case.keyExtractor(undefined, 5)returns'5'. Unfixed code fails this case.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)
Activityhides the list, it runs effect cleanups, and FlashList callsrecyclerViewManager.dispose()(useRecyclerViewManager.ts). When the list is shown again, the same effect callsrestoreIfNeeded().processDataUpdateruns inuseMemoduring render, so it runs even while the list is hidden (RecyclerViewManager.ts:302-315). A disposed manager skipsupdateScrollOffset(L111-L127). The render stack then syncs with the old engaged indices, andRenderStackManager.synccallsgetDataKeyfor each one without checking the data length.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
thelullabyy commentedon Oct 18, 2026
@MelvinBot Please implement your proposal
MelvinBot commentedon Oct 18, 2026
🤖 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
keyExtractorinBaseSelectionListWithSectionsfall back to the index whenitemis 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