Skip to content

[Due for payment 2026-06-09] [$250] [Sentry: APP-AZ6] TypeError: undefined is not an object (evaluating 't.reportID') in LHNOptionsList renderItem #91226

Closed

Description

@mountiny

Sentry

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

Impact (snapshot at filing)

  • Users (total since first seen): 1,689
  • Events: 1,818
  • Users (last 14d): ~110
  • First seen: 2026-04-22
  • Last seen: now (ongoing)
  • Platforms: web (standalone), seen on multiple browsers/OSes — primarily desktop
  • App version(s): seen through 9.3.77
  • Affected route(s) (parameterized): /r/:reportID

Stack trace (top first-party frames)

@shopify/flash-list ViewHolder.js renderItem({ item, index, extraData, target })
  → src/components/LHNOptionsList/LHNOptionsList.tsx:61
            const reportID = item.reportID;   // throws when item is undefined

Underlying React 19 wrapper:

react-dom-client.production.js  renderWithHooks → updateFunctionComponent → ViewHolder → renderItem

Suspected cause

item passed to LHNOptionsList's renderItem is undefined during a re-render — possibly an off-by-one or stale-index access in @shopify/flash-list's ViewHolder when the underlying data shrinks before the recycler view rebuilds. Could also be a transient empty slot returned by the data source while a report is being optimistically deleted.

Safe guard: defensively bail out when item == null in LHNOptionsList.tsx's renderItem:

if (!item) {
    return null;
}
const reportID = item.reportID;

That avoids the crash regardless of root cause and lets us instrument what condition caused item to be undefined.

Reproduction

Unknown — reproductions in Sentry are tied to specific accounts viewing /r/:reportID. The crash happens during concurrent rendering recovery, which suggests a state-change race on the LHN list.

Related

  • Sibling Sentry IDs (if any): none identified
  • Prior GH issues: closest neighbour is closed App#84349 (childReportID, different code path)
Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. self-assigned this
    on May 21, 2026
  2. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on May 21, 2026
  3. MelvinBot commented on May 21, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    @shopify/flash-list's ViewHolderCollection maintains an internal renderStack — a map of {reactKey → index} entries tracking which ViewHolders are mounted and at which data indices. When the data array shrinks (e.g., reports are removed from the LHN due to optimistic deletions, policy changes, or filter changes), the renderStack can still hold stale indices from the previous larger array. During concurrent rendering recovery on web, data[index] returns undefined for these out-of-bounds indices, which is passed directly to renderItem without any bounds check.

    The data source itself is clean — useSidebarOrderedReports uses .filter(Boolean) so the array never contains undefined entries. The issue is purely a stale-index race in FlashList's recycler.

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

    Add a defensive null guard at the top of renderItem in LHNOptionsList.tsx:60-61 to bail out when item is undefined:

    const renderItem = useCallback(
        ({item, index}: RenderItemProps): ReactElement => {
            if (!item) {
                return <></>;
            }
            const reportID = item.reportID;
            // ... rest unchanged

    Also guard keyExtractor since it has the same vulnerability:

    const keyExtractor = (item: Report) => `report_${item?.reportID ?? 'unknown'}`;

    What alternative solutions did you explore? (Optional)

    The upstream fix would be adding a bounds check in FlashList's ViewHolderCollection.tsx:179 (if (index >= data.length) return null). However, we don't control FlashList's release cycle, and the defensive guard in our code is safe, zero-cost, and immediately addresses the crash for all 1,689 affected users. Both fixes are complementary — the App-side guard is the right short-term solution regardless of whether an upstream fix lands.


    Next Steps for Contributor+ team: Reply with @MelvinBot implement this to create a draft PR, @MelvinBot <your feedback> to refine this analysis, or explain why you are rejecting Melvin's proposal.

  4. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on May 21, 2026
  5. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on May 21, 2026
  6. melvin-bot commented on May 21, 2026

    @melvin-bot

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

  7. KJ21-ENG commented on May 21, 2026

    @KJ21-ENG
    Contributor

    Proposal

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

    Some users crash on web while viewing a report route because the LHN FlashList row renderer is called with an undefined item, and LHNOptionsList immediately reads item.reportID.

    What is the root cause of that problem?

    LHNOptionsList assumes every renderItem invocation from FlashList has a defined report object:

    const renderItem = useCallback(
    ({item, index}: RenderItemProps): ReactElement => {
    const reportID = item.reportID;
    const itemReportAttributes = reportAttributes?.[reportID];
    const itemParentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.parentReportID}`];
    const itemOneTransactionThreadReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${itemReportAttributes?.oneTransactionThreadReportID}`];

    That assumption is too strict for this virtualized list boundary. The data passed into LHNOptionsList is derived from useSidebarOrderedReports, which maps ordered report IDs through Onyx and filters out missing reports before exposing orderedReports:

    (reportIDs: string[]): OnyxTypes.Report[] => {
    if (!chatReports) {
    return [];
    }
    return reportIDs.map((reportID) => chatReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]).filter(Boolean) as OnyxTypes.Report[];
    },
    [chatReports],
    );
    const orderedReports = useMemo(() => getOrderedReports(orderedReportIDs), [getOrderedReports, orderedReportIDs]);

    So the likely failure mode is not a persistent undefined entry in orderedReports; it is a transient recycler/render race while the LHN data changes during report navigation or optimistic report deletion. FlashList can still call the existing renderItem with an empty item during that reconciliation window, and the first line of the renderer throws before the row can safely disappear.

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

    Add a defensive null guard at the start of LHNOptionsList's renderItem and return null when FlashList gives us no item. Concretely, change the callback return type to ReactElement | null, add if (!item) { return null; } before const reportID = item.reportID;, and update the local RenderItemProps type to allow item to be nullable/optional.

    This keeps the fix at the virtualized-list boundary, avoids changing LHN ordering/cache logic, and prevents the crash regardless of whether the trigger is a stale FlashList index, a shrinking data array, or an optimistic report removal.

    For coverage, add a focused unit test around LHNOptionsList's renderer behavior or the nearest existing LHN render test: render the list path with a mocked FlashList renderItem call where item is undefined and assert it returns null/does not throw. Existing normal-row rendering should continue to cover defined report items.

    What alternative solutions did you explore? (Optional)

    I considered changing useSidebarOrderedReports to filter harder, but it already filters missing Onyx reports when building orderedReports. That does not protect against FlashList invoking renderItem with a stale or empty item during recycling, so the crash guard belongs in LHNOptionsList.

    I also considered deeper guards in OptionRowLHNData, but the crash happens before that component is rendered. Guarding at the first dereference is narrower and keeps invalid rows out of the row subtree entirely.

    At draft time I found no strict duplicate proposal/comment on the issue. The closest related issue is #84349, which was another undefined-property crash during deletion, but it involved childReportID in a different flow.

  8. wildan-m commented on May 21, 2026

    @wildan-m
    Contributor

    Proposal

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

    On web, the app crashes with TypeError: undefined is not an object (evaluating 't.reportID'). The crash originates in the renderItem callback of the left-hand navigation list (LHNOptionsList): FlashList invokes renderItem with an item that is undefined, and the very first line of the callback reads item.reportID, which throws. This is a high-volume, web-only crash that has affected a large number of users.

    What is the root cause of that problem?

    There are two parts: why the read is unsafe, and why an undefined item reaches it specifically on web.

    1. The first statement in renderItem reads item.reportID with no nullish check, even though the rest of the same callback already treats item as possibly missing (it uses item?.invoiceReceiver and item?.policyID further down). So the function is internally inconsistent — it guards item everywhere except the one line that crashes:

    */
    const renderItem = useCallback(
    ({item, index}: RenderItemProps): ReactElement => {
    const reportID = item.reportID;
    const itemReportAttributes = reportAttributes?.[reportID];
    const itemParentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.parentReportID}`];
    const itemOneTransactionThreadReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${itemReportAttributes?.oneTransactionThreadReportID}`];
    let invoiceReceiverPolicyID = '-1';
    if (item?.invoiceReceiver && 'policyID' in item.invoiceReceiver) {
    invoiceReceiverPolicyID = item.invoiceReceiver.policyID;
    }
    if (itemParentReport?.invoiceReceiver && 'policyID' in itemParentReport.invoiceReceiver) {
    invoiceReceiverPolicyID = itemParentReport.invoiceReceiver.policyID;
    }
    const itemInvoiceReceiverPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiverPolicyID}`];
    const itemPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${item?.policyID}`];

    1. The crash is web-only, and the only web-specific behavior in this component is the FlashList initialScrollIndex, which is set to a saved scroll index on web and left undefined on native:

    onLayout={onLayout}
    onScroll={onScroll}
    initialScrollIndex={isWeb ? getScrollIndex(route) : undefined}
    maintainVisibleContentPosition={{disabled: true}}
    drawDistance={250}
    removeClippedSubviews
    />

    getScrollIndex(route) returns an index that was saved earlier from onScroll as Math.floor(contentOffset.y / estimatedItemSize):

    // We should ignore this case.
    if (e.nativeEvent.layoutMeasurement.height === 0) {
    return;
    }
    saveScrollOffset(route, e.nativeEvent.contentOffset.y);
    if (isWeb) {
    saveScrollIndex(route, Math.floor(e.nativeEvent.contentOffset.y / estimatedItemSize));
    }
    triggerScrollEvent();
    },
    [estimatedItemSize, route, saveScrollIndex, saveScrollOffset, triggerScrollEvent],

    Nothing ever re-validates that saved index against the current list length. The data passed to the list is the dynamic report list (optionListItems), whose size changes constantly — reports get archived/deleted, search and filter results change, priority mode is toggled, accounts are switched. When the list shrinks after an index was saved at a deeper scroll position, the saved index can be larger than the current data.length. When FlashList mounts (or re-mounts during a concurrent-rendering recovery) and honors that out-of-range initialScrollIndex, it looks up data[index], gets undefined, and hands that undefined to renderItem — where item.reportID throws. On native, initialScrollIndex is undefined, so FlashList never starts from an out-of-range row, which is exactly why the crash is web-only.

    A compounding factor: in ScrollOffsetContextProvider, saveScrollOffset/getScrollOffset and saveScrollIndex/getScrollIndex all read and write the same map entry, scrollOffsetsRef.current[getKey(route)]. The pixel offset and the row index share one storage slot per route and overwrite each other, which makes the stored index even less trustworthy:

    }, [priorityMode, previousPriorityMode]);
    const saveScrollOffset: ScrollOffsetContextValue['saveScrollOffset'] = useCallback((route, scrollOffset) => {
    scrollOffsetsRef.current[getKey(route)] = scrollOffset;
    }, []);
    const getScrollOffset: ScrollOffsetContextValue['getScrollOffset'] = useCallback((route) => {
    if (!scrollOffsetsRef.current) {
    return;
    }
    return scrollOffsetsRef.current[getKey(route)];

    const saveScrollIndex: ScrollOffsetContextValue['saveScrollIndex'] = useCallback((route, scrollIndex) => {
    scrollOffsetsRef.current[getKey(route)] = scrollIndex;
    }, []);
    const getScrollIndex: ScrollOffsetContextValue['getScrollIndex'] = useCallback((route) => {
    if (!scrollOffsetsRef.current) {
    return;
    }
    return scrollOffsetsRef.current[getKey(route)];

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

    Two changes — one defensive (stops the crash regardless of source) and one that removes the actual web-only trigger.

    1. In LHNOptionsList's renderItem, bail out early when item is nullish: return null for that row before reading item.reportID. This makes the callback consistent with the optional chaining it already uses lower down and guarantees the crash cannot happen no matter which path produced an undefined item. Returning null simply renders an empty cell for that transient slot, which FlashList immediately reconciles away on the next render.

    2. Stop feeding FlashList an out-of-range start index. At the initialScrollIndex prop, only pass the saved index when it is actually valid for the current data — i.e. when data is non-empty and the saved index is >= 0 and < data.length. Otherwise pass undefined so the list starts from the top instead of being asked to render a row that does not exist. This addresses the root cause directly: FlashList is never told to render a non-existent item in the first place, so the defensive guard in change Some initial fixes and code style updates #1 should rarely (if ever) need to fire on web.

    Together these eliminate both the symptom (the unguarded read) and the cause (the stale, out-of-bounds web start index).

    Compare branch: https://github.com/Expensify/App/compare/main...wildan-m:App:wildan/91226-lhn-options-list-undefined-item?expand=1

    What alternative solutions did you explore? (Optional)

    • Clamp the saved index to data.length - 1 rather than dropping it. Rejected: when the list has shrunk a lot, snapping the user to the last remaining row is worse UX than starting at the top, and when the list is empty data.length - 1 is -1, which is itself invalid.
    • Give saveScrollIndex/getScrollIndex their own storage key in ScrollOffsetContextProvider, separate from the offset. This is a worthwhile cleanup of the shared-slot collision, but on its own it does not prevent an out-of-bounds index after the list shrinks, so the in-bounds validation in change Fix spaces #2 is still required. It can be done as a follow-up.
  9. changed the title [-][Sentry: APP-AZ6] TypeError: undefined is not an object (evaluating 't.reportID') in LHNOptionsList renderItem[/-] [+][$250] [Sentry: APP-AZ6] TypeError: undefined is not an object (evaluating 't.reportID') in LHNOptionsList renderItem[/+] on May 21, 2026
  10. melvin-bot commented on May 21, 2026

    @melvin-bot
  11. aswin-s commented on May 21, 2026

    @aswin-s
    Contributor

    Proposal

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

    On web/desktop only, LHNOptionsList's FlashList renderItem crashes with TypeError: undefined is not an object (evaluating 't.reportID') because item is undefined. Sentry APP-AZ6 reports 1,689 affected users (≈110 in the last 14 days) since 2026-04-22 on route /r/:reportID. The list should render the row or skip it without crashing the sidebar.

    What is the root cause of that problem?

    Two issues compound — one explains the unsafe read, one explains why the crash is web-only.

    1. renderItem is internally inconsistent. At src/components/LHNOptionsList/LHNOptionsList.tsx:59-74, the very first line reads item.reportID with no nullish check — yet a few lines below the same callback already treats item as possibly missing (item?.invoiceReceiver, item?.policyID). The committed data from useSidebarOrderedReports is filtered with .filter(Boolean) (useSidebarOrderedReports.tsx:286-291), so the array itself is clean — but FlashList can still hand renderItem an undefined slot from its recycler pool, and the first dereference throws before the row can no-op.

    2. Web-only trigger: stale initialScrollIndex is not validated against data.length. LHNOptionsList.tsx:160 sets initialScrollIndex={isWeb ? getScrollIndex(route) : undefined}. The saved index is written by onScroll (line 116-126) as Math.floor(contentOffset.y / estimatedItemSize) and is never re-validated when the list later shrinks (priority-mode toggle, archive/delete, account switch, filter change). When FlashList mounts/remounts with an out-of-range initialScrollIndex, it reads data[index], gets undefined, and passes it to renderItem. Native passes undefined, so the crash is web-only — exactly matching Sentry's platform filter.

    3. Compounding bug in ScrollOffsetContextProvider. Both saveScrollOffset and saveScrollIndex write to the same map entry scrollOffsetsRef.current[getKey(route)] (src/components/ScrollOffsetContextProvider.tsx:80-81 and :125-126). The pixel offset and the row index share one slot per route and overwrite each other on every scroll event, so getScrollIndex can return whatever the last writer happened to put there — pixel offsets read as indices into a much smaller array.

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

    Three changes — one defensive (stops the crash regardless of source), two that fix the actual web-only triggers.

    1. Guard renderItem and keyExtractor in LHNOptionsList.tsx. Change the callback return type to ReactElement | null, and at the top of renderItem add if (!item) { return null; } before const reportID = item.reportID;. Update RenderItemProps['item'] in src/components/LHNOptionsList/types.ts to be optional. Harden keyExtractor at line 24 to (item, index) => report_${item?.reportID ?? unknown_${index}}`` so an undefined-slot pass cannot throw inside FlashList's key resolution either.

    2. Clamp initialScrollIndex against current data.length in LHNOptionsList.tsx:160. Compute it once: const initialScrollIndex = useMemo(() => { if (!isWeb) return undefined; const saved = getScrollIndex(route); return typeof saved === 'number' && saved >= 0 && saved < data.length ? saved : undefined; }, [getScrollIndex, route, data.length]);. This removes the actual trigger that made the crash web-only and keeps the saved-scroll restore feature working in the common case (list size unchanged).

    3. Split storage for offset and index in ScrollOffsetContextProvider.tsx. Use separate ref maps (e.g. scrollOffsetsRef and scrollIndexesRef) so saveScrollOffset/saveScrollIndex no longer clobber each other's entries. Update cleanStaleScrollOffsets and the priority-mode reset effect to clean both maps. This removes the silent corruption where getScrollIndex returns a pixel offset.

    No backend or native changes — LHNOptionsList.tsx and ScrollOffsetContextProvider.tsx are shared web modules; the crash is reported only on web/desktop, matching the web-only initialScrollIndex path.

    What alternative solutions did you explore? (Optional)

    • Defensive guard alone (Melvin/KJ21 / the original posted proposal): necessary but insufficient. It hides the symptom but leaves the actual web-only trigger (stale initialScrollIndex) in place — the list will still start at a wrong scroll position when the saved index is stale, and the corrupted shared-slot storage will continue to feed misleading values into a defensive return null. Users would see brief blank rows on every cold render of a shrunken list.
    • Hoisting the guard into OptionRowLHNData: rejected — the throw happens at the first dereference in renderItem, before OptionRowLHNData is reached.
    • Filter harder in useSidebarOrderedReports: rejected — it already uses .filter(Boolean); the undefined originates inside FlashList's recycler reading data[staleIndex], not from the upstream array.
    • Upstream fix in @shopify/flash-list (ViewHolderCollection.tsx bounds check): out of our release cycle; complementary, not a substitute.
  12. 38 remaining items

  13. melvin-bot commented on Jun 12, 2026

    @melvin-bot

    @mallenexpensify Whoops! This issue is 2 days overdue. Let's get this updated quick!

  14. gijoe0295 commented on Jun 16, 2026

    @gijoe0295
    Contributor

    @mallenexpensify This is due

  15. mallenexpensify commented on Jun 16, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor: @wildan-m paid $250 via Upwork
    Contributor+: @gijoe0295 due $250 via NewDot

    no tests needed.

  16. JmillsExpensify commented on Jun 16, 2026

    @JmillsExpensify
    Contributor

    $250 approved for @gijoe0295

  17. mountiny commented on Jul 2, 2026

    @mountiny
    ContributorAuthor

    Fresh Sentry numbers for APP-AZ6 (closed tracker — still seeing events):

    • Users (last 7d): 8
    • Events (last 7d): 8
    • Platform: web — LHN renderItem reading reportID on undefined
    • Last seen: 2026-06-30
  18. moved this from Done to SUBISSUE in [#whatsnext] #qualityon Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething 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