Skip to content

[$175] [Sentry: APP-MHA] HybridApp iOS: FlashList onScroll throws "LayoutManager is not initialized" #104502

Open

Description

@mountiny

Sentry

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

Impact (snapshot at filing)

  • Users (total since first seen): 37
  • Events: 106
  • Users (last 14d, all versions): 21
  • Users (last 14d, production 9.5.11 to 9.5.13): 7 (54 events)
  • First seen: 2026-09-30 (about 3 weeks ago)
  • Last seen: 2026-10-20 (ongoing)
  • Platform: iOS HybridApp (Hermes, Fabric)
  • App version(s): 9.4.94 to 9.5.14. Most users are on 9.5.13 and 9.5.5.
  • Affected route(s) (parameterized): native screen; the sample event was on Workspaces_List
  • Mechanism: onerror, level fatal

Stack trace (top frames, first-party only)

The stack has no first-party frame. These are the FlashList frames:

@shopify/flash-list/dist/recyclerview/RecyclerView.js:269        onScroll handler -> recyclerViewManager.recordInteraction()
@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js:218 recordInteraction -> itemViewabilityManager.recordInteraction()
getWindowSize -> Error: LayoutManager is not initialized, window size is unavailable

Bundle frames in order: dispatchEvent -> batchedUpdates$1 -> executeDispatch -> recordInteraction -> getWindowSize.

Suspected cause

A native scroll event reaches a FlashList before its layout manager exists, or after it is torn down. FlashList's own onScroll calls recordInteraction(), which reads the window size and throws. The App already catches this error for the synthetic scroll path in src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx (around line 114), but native scroll events go through FlashList's onScroll with no guard.

Reproduction

Unknown. See the Sentry events linked from the Sentry issue.

Related

Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @linhvovan29546

Activity

  1. self-assigned this
    on Oct 20, 2026
  2. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Oct 20, 2026
  3. melvin-bot commented on Oct 20, 2026

    @melvin-bot

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

  4. changed the title [-][Sentry: APP-MHA] HybridApp iOS: FlashList onScroll throws "LayoutManager is not initialized"[/-] [+][$175] [Sentry: APP-MHA] HybridApp iOS: FlashList onScroll throws "LayoutManager is not initialized"[/+] on Oct 20, 2026
  5. melvin-bot commented on Oct 20, 2026

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

    @haiquywork

    Proposal

    What is the root cause of that problem?

    The throw is inside FlashList 2.3.2. Its onScroll calls recyclerViewManager.recordInteraction() once isInitialScrollComplete is true, and that calls ViewabilityManager.updateViewableItems(). updateViewableItems() calls getWindowSize(), which throws when layoutManager is undefined. The layoutManager is undefined before the first measure and is reset to undefined when it is marked dirty (RecyclerViewManager.js ~L157). FlashList's own computeItemViewability() sits behind shouldListenToVisibleIndices, but recordInteraction() has no hasLayout() check. A native scroll event that lands in that window therefore throws from the React event handler, and onerror reports it as fatal.
    In our code src/components/Table/TableBody.tsx (the WorkspaceListTable list) and src/components/FlashList/index.tsx pass the FlashList straight through. Only the synthetic-scroll path in ExternalScrollFlashListTable.tsx has a try/catch. I could not reproduce this on a device. I confirmed the FlashList code path by reading node_modules.

    useEffect(() => {
    if (!store) {
    return;
    }
    const emit = () => {
    // Offset into the nested list's own coordinate space (FlashList subtracts its measured firstItemOffset —
    // the header height — internally). Only `contentOffset.y` is read for a vertical list, so nothing else is
    // populated; windowing comes from the `overrideWindowSize` prop, not this event.
    const y = Math.max(0, store.getOffset() - offsetTop);
    try {
    // Synthesizing a native scroll event requires an assertion: NativeSyntheticEvent's target/currentTarget
    // are RN HostInstances that can't be constructed in JS. FlashList's vertical handler reads only
    // contentOffset.y.
    // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
    onScroll?.({nativeEvent: {contentOffset: {x: 0, y}}} as NativeSyntheticEvent<NativeScrollEvent>);
    } catch {
    // During back-navigation teardown FlashList can be driven after its layout manager is gone, which
    // throws ("LayoutManager is not initialized"). This call only feeds a synthetic scroll frame to
    // window the nested list, so a dropped frame here is harmless — swallow it rather than crash.
    }
    };
    // Seed the initial window, then track subsequent parent scrolls. Re-subscribes only when the store, the
    // measured offset, or FlashList's scroll handler change — none of which happen per scroll frame.
    emit();

    <FlashList<TableData>
    ref={(instance) => {
    listRef.current = instance;
    measureScrollbarRef(instance);
    }}
    data={listData}
    style={[styles.flex1, styles.mnh0]}
    maintainVisibleContentPosition={{disabled: true}}
    ListHeaderComponent={listHeaderContent}
    ListHeaderComponentStyle={[
    ListHeaderComponentStyle,
    // An unmeasured page header leaves nothing to offset the stack by, and 0 would pin it instead.
    pageHeaderHeight > 0 && shouldRenderColumnHeaderInListHeader && getColumnScrollListHeaderStyle(pageHeaderHeight),
    ]}
    ListEmptyComponent={shouldRenderEmptyStateInList ? emptyStateContent : ListEmptyComponent}
    ListEmptyComponentStyle={[ListEmptyComponentStyle, shouldRenderEmptyStateInList && styles.flexGrow1, shouldRenderEmptyStateInList && styles.justifyContentCenter]}
    ListFooterComponent={ListFooterComponent}
    ListFooterComponentStyle={shouldRenderEmptyStateInList ? emptyStateFooterStyle : ListFooterComponentStyle}
    onLoad={handleLoad}
    onChangeStickyIndex={handleChangeStickyIndex}
    stickyHeaderIndices={hasRows && canRenderStickyHeader ? adjustedStickyHeaderIndices : undefined}
    contentContainerStyle={[
    listContentContainerStyle,
    tableBodyContentContainerStyle,
    contentContainerStyle,
    // Absolutely positioned rows don't widen the scroller's content, so hold the scroll extent open.
    isColumnScrollEnabled && StyleUtils.getMinimumWidth(scrollWidth),
    shouldRenderEmptyStateInList && styles.flexGrow1,
    shouldUseNarrowTableLayout &&
    typeof contentMinHeight === 'number' &&
    typeof tableBodyBottomPadding === 'number' && {
    minHeight: contentMinHeight + tableBodyBottomPadding,
    },
    ]}
    keyboardShouldPersistTaps="handled"
    onScrollBeginDrag={handleScrollBeginDrag}
    renderItem={renderListItem}
    keyExtractor={keyExtractorForList}
    getItemType={getItemTypeForList}
    onEndReached={hasRows ? onEndReached : undefined}
    onStartReached={hasRows ? onStartReached : undefined}
    initialScrollIndex={initialScrollIndexForList}
    onViewableItemsChanged={hasRows && onViewableItemsChanged ? handleViewableItemsChanged : undefined}
    overrideItemLayout={overrideItemLayout ? overrideItemLayoutForList : undefined}
    viewabilityConfigCallbackPairs={hasRows ? viewabilityConfigCallbackPairsForList : undefined}
    onScroll={(event) => {
    trackScrollOffset(event);
    onScroll?.(event);
    }}
    {...restListProps}
    scrollEnabled={scrollEnabled}

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

    • The cleanest fix is to guard recordInteraction in the library. Patch @shopify/flash-list with patch-package, which the repo already uses, so recordInteraction() or updateViewableItems() returns early when !hasLayout(). computeItemViewability already behaves this way.
    • This fixes the write site, so every FlashList in the app is covered, including the ones that don't wrap src/components/FlashList. I'd also send the same guard upstream to Shopify.
    • If a patch isn't wanted, the fallback is to stop un-guarded scroll events from reaching the list. This does not stop the throw from RecyclerView.onScroll itself, which is why I prefer the patch.
    • Add a patch note in patches/@shopify/flash-list/details.md, as the repo requires for patches.
    • Add a unit test next to the existing FlashList tests. Given a RecyclerViewManager with no layout, When recordInteraction() is called, Then it does not throw. It should fail on the unpatched code.

    What alternative solutions did you explore? (Optional)

    • Wrap onScroll in try/catch in TableBody.tsx. This doesn't work, because FlashList throws before it calls the user's onScroll, and it would only cover one list.
    • Catch the error globally. This hides real errors.
  7. jloa-dev commented on Oct 21, 2026

    @jloa-dev

    Contributor details
    Your Expensify account email: [email protected]
    Upwork Profile Link: https://www.upwork.com/freelancers/~0163955a2ba221e513

    Proposal

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

    On iOS HybridApp (Hermes / Fabric), scrolling a FlashList (e.g. on native screen Workspaces_List or during screen transitions) crashes the application with a fatal error:
    Error: LayoutManager is not initialized, window size is unavailable
    thrown from @shopify/flash-list/dist/recyclerview/RecyclerViewManager.js -> getWindowSize(), called via recyclerViewManager.recordInteraction() from RecyclerView.js:269's onScroll handler.

    What is the root cause of that problem?

    1. On iOS Fabric, native scroll momentum events (onScroll) can be dispatched asynchronously from the native UI thread while React Native's layout pass has not yet completed (e.g., during navigation push/pop, or when the screen is hidden via display: none where our patch @shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch intentionally skips layout measurement).
    2. When the native scroll event fires, RecyclerView.js's onScroll handler invokes:
      recyclerViewManager.recordInteraction();
    3. Inside RecyclerViewManager.js, recordInteraction() directly delegates to:
      this.itemViewabilityManager.recordInteraction();
      which triggers updateViewableItems() and calls this.recyclerViewManager.getWindowSize().
    4. getWindowSize() requires an active layoutManager:
      getWindowSize() {
          if (!this.layoutManager) {
              throw new Error("LayoutManager is not initialized, window size is unavailable");
          }
          return this.layoutManager.getWindowSize();
      }
      Because layoutManager is undefined before the first measurement or after the list is hidden/dirtied, calling recordInteraction() causes an unhandled exception that crashes the hybrid app.

    What alternative solutions did you explore? (Optional)

    Catching the error with try / catch inside ExternalScrollFlashListTable.tsx only protects the synthetic scroll path of MoneyRequestReportView, but does not protect native FlashLists throughout the app (such as Workspaces_List or SearchRouter). Fixing this at the FlashList patch level protects all lists globally.

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

    Add a patch in patches/@shopify/flash-list/ (or update patch 002 / create patch 017+fix-record-interaction-without-layout.patch):

    1. In node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js (and src counterpart):
      Guard recordInteraction() using hasLayout():

      --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js
      +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js
      @@ -215,6 +215,9 @@ export class RecyclerViewManager {
           }
           recordInteraction() {
      +        if (!this.hasLayout()) {
      +            return;
      +        }
               this.itemViewabilityManager.recordInteraction();
           }
    2. In node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js (and src counterpart):
      Also add a safeguard in the onScroll callback:

      --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
      +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
      @@ -266,7 +266,9 @@ const RecyclerViewComponent = (props, ref) => {
               if (isInitialScrollComplete) {
      -            recyclerViewManager.recordInteraction();
      +            if (recyclerViewManager.hasLayout()) {
      +                recyclerViewManager.recordInteraction();
      +            }
               }
    3. Update patches/@shopify/flash-list/details.md documenting this safeguard alongside the existing scrollToIndex guard in patch 002.

    What automated tests can you add to prevent this from re-occurring?

    Add a test in tests/unit/FlashListTest.tsx (or a unit test verifying RecyclerViewManager) that triggers recordInteraction() on a newly instantiated RecyclerViewManager whose layoutManager is undefined, verifying that it safely early-returns without throwing "LayoutManager is not initialized, window size is unavailable".

  8. melvin-bot commented on Oct 21, 2026

    @melvin-bot

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

  9. magomedta commented on Oct 21, 2026

    @magomedta

    Contributor details
    Your Expensify account email: [email protected]
    Upwork Profile Link: N/A (Expensify NewDot Direct Settlement)

    Proposal

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

    On iOS HybridApp (Hermes / Fabric), during screen transitions or before/after layout computation (such as on Workspaces_List or back-navigation teardown), native scroll events dispatch to FlashList and crash the app with a fatal error:
    Error: LayoutManager is not initialized, window size is unavailable
    thrown from @shopify/flash-list/dist/recyclerview/RecyclerViewManager.js -> getWindowSize(), called via itemViewabilityManager.recordInteraction().


    What is the root cause of that problem?

    When a native scroll event arrives from iOS Fabric, RecyclerView.js executes its onScroll handler:

    // src/recyclerview/RecyclerView.tsx / dist/recyclerview/RecyclerView.js:183-188
    if (recyclerViewManager.isInitialScrollComplete) {
        recyclerViewManager.recordInteraction();
    }
    recyclerViewManager.computeItemViewability();

    Notice the immediate architectural disparity between the two calls:

    1. computeItemViewability() (line 186) safely guards itself against uninitialized layout:
      computeItemViewability() {
          if (!this.shouldListenToVisibleIndices() || !this.hasLayout()) {
              return;
          }
          ...
      }
    2. In contrast, recordInteraction() (line 184) has no hasLayout() guard:
      recordInteraction() {
          this.itemViewabilityManager.recordInteraction();
      }
    3. Inside ViewabilityManager.js:16-29, recordInteraction() immediately mutates state:
      this.recordInteraction = () => {
          if (this.hasInteracted) {
              return;
          }
          this.hasInteracted = true;
          this.viewabilityHelpers.forEach((viewabilityHelper) => {
              viewabilityHelper.hasInteracted = true;
          });
          this.updateViewableItems();
      };
      and invokes updateViewableItems():
      this.updateViewableItems = (newViewableIndices) => {
          const listSize = this.rvManager.getWindowSize();
          if (listSize === undefined || !this.shouldListenToVisibleIndices) {
              return;
          }
          ...
    4. The Broken API Contract Assumption:
      ViewabilityManager.js line 29 explicitly assumes that this.rvManager.getWindowSize() can return undefined when the window size / layout is not yet ready (if (listSize === undefined)).
      However, RecyclerViewManager.js:118-123 does not return undefined; it throws unconditionally:
      getWindowSize() {
          if (!this.layoutManager) {
              throw new Error(ErrorMessages.layoutManagerNotInitializedWindowSize);
          }
          return this.layoutManager.getWindowsSize();
      }
      Because native momentum scroll events can fire asynchronously on iOS Fabric before layout measurement completes or after a screen starts tearing down (also noted in ExternalScrollFlashListTable.tsx:113-115), layoutManager is undefined, causing getWindowSize() to throw an unhandled fatal exception.
    5. In addition, mutating this.hasInteracted = true before verifying layout means that if an interaction occurs prior to layout initialization, the list latches its interaction state prematurely before viewable items can actually be evaluated.

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

    We should patch @shopify/flash-list (following the exact pattern used in patches 002, 009, and 016):

    1. Guard recordInteraction in node_modules/@shopify/flash-list/src/recyclerview/RecyclerViewManager.ts & dist/recyclerview/RecyclerViewManager.js:
      Ensure recordInteraction() early-returns when hasLayout() is false, exactly like computeItemViewability():

      --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js
      +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerViewManager.js
      @@ -216,6 +216,9 @@ export class RecyclerViewManager {
           }
           recordInteraction() {
      +        if (!this.hasLayout()) {
      +            return;
      +        }
               this.itemViewabilityManager.recordInteraction();
           }
           recomputeViewableItems() {

      This prevents premature latching of hasInteracted = true and preserves interaction tracking for the subsequent scroll once layout completes.

    2. Guard updateViewableItems in node_modules/@shopify/flash-list/src/recyclerview/viewability/ViewabilityManager.ts & dist/recyclerview/viewability/ViewabilityManager.js:
      Align the implementation with its intended design (handling uninitialized layout without throwing):

      --- a/node_modules/@shopify/flash-list/dist/recyclerview/viewability/ViewabilityManager.js
      +++ b/node_modules/@shopify/flash-list/dist/recyclerview/viewability/ViewabilityManager.js
      @@ -26,6 +26,9 @@ export class ViewabilityManager {
               this.updateViewableItems = (newViewableIndices) => {
                   var _a;
      +            if (!this.rvManager.hasLayout()) {
      +                return;
      +            }
                   const listSize = this.rvManager.getWindowSize();
                   if (listSize === undefined || !this.shouldListenToVisibleIndices) {
                       return;
    3. Register patch in patches/@shopify/flash-list/ & update details.md:
      Add @shopify+flash-list+2.3.2+017+guard-viewability-record-interaction-layout.patch (or fold into patches/@shopify/flash-list/), with comprehensive documentation in patches/@shopify/flash-list/details.md following the repo's mandatory patch protocol.


    What alternative solutions did you explore? (Optional)

    1. Catching inside ExternalScrollFlashListTable.tsx / TableBody.tsx:
      ExternalScrollFlashListTable.tsx:112 already has a local try/catch for synthetic scroll events, but native screens (e.g. Workspaces_List, SearchRouter) use native FlashList scrolling where React event dispatches bypass local wrappers. A library-level patch is required to protect all FlashLists across all platforms.
    2. Changing getWindowSize() to return undefined:
      While ViewabilityManager.js line 29 expects undefined, changing getWindowSize() return signature affects TypeScript declarations and external consumers of RecyclerViewManager. Adding if (!this.hasLayout()) return; to recordInteraction() and updateViewableItems() cleanly stops the error at the source while maintaining strict API type contracts.

    What specific scenarios should we cover in automated tests?

    Add a unit test in tests/unit/FlashListTest.tsx (or RecyclerViewManagerTest.tsx):

    • Instantiate a RecyclerViewManager with mock props and layoutManager = undefined.
    • Call recordInteraction().
    • Assert that it does not throw Error: LayoutManager is not initialized, window size is unavailable.
    • Verify that once layoutManager is provided and initialized, calling recordInteraction() successfully updates viewability state.
  10. melvin-bot commented on Oct 21, 2026

    @melvin-bot

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

  11. linhvovan29546 commented on Oct 21, 2026

    @linhvovan29546
    Contributor

    @MelvinBot Please analyze the issue and provide a proposal.

  12. MelvinBot commented on Oct 21, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    Our own FlashList patch 002 (skip layout when hidden) causes this. When a list first mounts at 0×0 (a screen that is hidden or not sized yet), the patch skips the first layout. So FlashList never creates its layoutManager.

    Upstream FlashList always creates it on the first layout, so upstream never hits this.

    Until a real onLayout arrives, any native scroll event crashes the list. On iOS, UIKit can send a scroll event with no user touch, for example when it adjusts the content inset under the tab bar. FlashList's onScroll then calls recordInteraction() → updateViewableItems(). That reads getWindowSize() before it checks whether the list has any viewability listeners, and getWindowSize() throws.

    Workspaces_List has no viewability callbacks, yet it still crashes because of that check order.

    Code references

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

    Extend patch 002 so the scroll path handles the "no layout yet" state it creates:

    1. In ViewabilityManager.updateViewableItems, return early when there are no viewability listeners or rvManager.hasLayout() is false. Do both checks before calling getWindowSize(). This fixes every list without viewability callbacks, including Workspaces_List.
    2. In RecyclerViewManager.recordInteraction, return early when hasLayout() is false. Then hasInteracted is not set before the list can measure anything, so waitForInteraction still works on the next scroll.
    3. In RecyclerViewManager.computeItemViewability, add a hasLayout() check. This covers lists that use onViewableItemsChanged.
    4. Update the patch 002 entry in patches/@shopify/flash-list/details.md.
    5. Add a unit test: a RecyclerViewManager with no layout does not throw from recordInteraction() or computeItemViewability(), and viewability still fires after the first layout.

    What alternative solutions did you explore? (Optional)

    • Remove patch 002. This fixes the crash but brings back the hidden-screen layout bugs the patch was added for.
    • Guard only recordInteraction, as the other proposals suggest. Lists with viewability callbacks would still crash through computeItemViewability.
    • try/catch in App components. FlashList throws before it calls our onScroll, so a try/catch in our code cannot catch it.
    Confidence and what's unverified

    The code path is confirmed. The exact iOS trigger is not verified on a device. It is either a UIKit inset adjustment or a tab-mount race on a list first measured at 0×0. Sentry breadcrumbs could confirm it: look for a tab switch or navigation just before the crash, with no onScrollBeginDrag. The fix works whichever trigger it is.


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


    view run

  13. magomedta commented on Oct 21, 2026

    @magomedta

    Hi @linhvovan29546 — a quick note for the review: as detailed in our proposal above, we specifically identified both RecyclerViewManager.recordInteraction() and ViewabilityManager.updateViewableItems() requiring the hasLayout() guards (preventing premature latching of hasInteracted = true and guarding before getWindowSize()). We can also include the defensive computeItemViewability() guard and the unit test in the patch/PR when assigned. Ready to submit the PR immediately upon approval. Thanks!

  14. mountiny commented on Oct 21, 2026

    @mountiny
    ContributorAuthor

    @linhvovan29546 please choose a proposal. Thank you.

  15. linhvovan29546 commented on Oct 22, 2026

    @linhvovan29546
    Contributor

    MelvinBot's proposal LGTM!

    🎀 👀 🎀 C+ reviewed

  16. melvin-bot commented on Oct 22, 2026

    @melvin-bot

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

  17. mountiny commented on Oct 22, 2026

    @mountiny
    ContributorAuthor

    @linhvovan29546 thanks! please go ahead

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

Metadata

Metadata

Labels

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