Skip to content

[$175] [Sentry: APP-KC9] Navigation crash: 'filter' of undefined in getStateForRouteNamesChange #103777

Open

Description

@mountiny

Sentry

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

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

  • Users (total since first seen): 43
  • Users (last 30d, releases 9.5.5 to 9.5.7 only): 20
  • Events (30d window): 83
  • First seen: 2026-08-24
  • Last seen: ongoing
  • Platform: iOS hybrid app
  • App version(s): 9.5.5
  • Affected route(s): Search report (SearchReport)
  • Mechanism: generic (unhandled TypeError during render)

Stack trace (top frames, first-party only)

PlatformNavigatorImpl
useNavigationBuilder
getStateForRouteNamesChange

Suspected cause

Navigator state has an undefined routes array when route names change, so .filter throws. It may relate to the react-navigation 7.x upgrade or to a screen mounting with a missing state.

Reproduction

Unknown. See the events linked from the Sentry issue.

Related

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

Activity

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

    @melvin-bot

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

  4. changed the title [-][Sentry: APP-KC9] Navigation crash: 'filter' of undefined in getStateForRouteNamesChange[/-] [+][$175] [Sentry: APP-KC9] Navigation crash: 'filter' of undefined in getStateForRouteNamesChange[/+] on Oct 12, 2026
  5. melvin-bot commented on Oct 12, 2026

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

    @mountiny
    ContributorAuthor

    (Vit's agent) @eh2077, 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 React Navigation knowledge?

  7. MelvinBot commented on Oct 12, 2026

    @MelvinBot
    Contributor

    ⚠️ Preliminary analysis (low confidence): treat this as a starting point, not a conclusion.

    The crash is in react-navigation's stock StackRouter.getStateForRouteNamesChange. It calls state.routes.filter(...) and state.preloadedRoutes.filter(...) without null checks (StackRouter.tsx:270-276, routers 7.6.4). No App router overrides this method.

    It throws only when two things happen together:

    • A navigator gets a stack state marked stale: false that has routeNames but no routes or preloadedRoutes.
    • The navigator's screen list changes at the same time.

    getRehydratedState returns stale: false states unchanged (StackRouter.tsx:205-207), so the incomplete state is never repaired.

    The most likely place for the screen list to change is the root stack. It renders the onboarding navigator conditionally, and part of that condition is an imperative navigation-state read during render (AuthScreens.tsx:354). Swapping between AuthScreens and PublicScreens on HybridApp also changes the root's screen names.

    I couldn't confirm the root cause because I couldn't find any App code that builds a state without routes or preloadedRoutes. Sentry was also unreachable from this run. To confirm, check the Sentry events for:

    1. The column number. It tells you whether line 270 (routes) or line 276 (preloadedRoutes) throws.
    2. Breadcrumbs just before the crash: onboarding completion, login or logout, OldDot↔NewDot switches, validate-login or 2FA flows.

    Possible defensive fix: wrap getStateForRouteNamesChange in the App's custom routers so they default routes and preloadedRoutes to [], then call the stock router. StackRouter already handles an empty routes array. Also add a Log.hmmm call when either field is missing, so the source can be found.

    Investigation details
    • How a bad state reaches the router: useNavigationBuilder uses a stale: false state from the parent or container as-is. If its routeNames differ from the rendered screens, it goes straight to getStateForRouteNamesChange.
    • App code that injects state: I checked these places. None of them drops routes or preloadedRoutes, but each passes stale: false state through without rehydrating it:
      • Preserved navigator states in RightModalRouter, SearchFullscreenRouter and SplitRouter.
      • Root resets in preMountBuffer.ts, Navigation.ts and NavigationRoot.tsx (after logout).
      • Tab state rebuilds in GetStateForActionHandlers.ts.
    • Version note: the first-seen date (2026-08-24) falls in the 9.4.59–9.4.60 window. The react-navigation 7.22.1 upgrade landed after 9.5.5, so it isn't the trigger. The router code is identical in both versions.
    • Related: ScreenActivityWrapper (perf(navigation): add per-screen Activity behavior for covered screens, merged 2026-08-31) clears the parent's route.state when a covered screen is hidden. This makes state rebuilt from getRootState() more fragile, but it landed after the first-seen date.
    • No related open issues found.

    Next Steps: Reply with @MelvinBot <your feedback> to refine this analysis.


    view run

  8. ashusatyarthi-wq commented on Oct 12, 2026

    @ashusatyarthi-wq

    Contributor details
    Your Expensify account email: ashusatyarthi@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~010e53044b5828fe60

    Proposal

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

    On iOS hybrid app (and across platforms during dynamic route/screen transitions such as mounting SearchReport), the app crashes with an unhandled TypeError: Cannot read properties of undefined (reading 'filter') inside getStateForRouteNamesChange called via PlatformNavigatorImpl > useNavigationBuilder.

    What is the root cause of that problem?

    In React Navigation 7.x (@react-navigation/routers/src/StackRouter.tsx:270-276), getStateForRouteNamesChange is invoked by useNavigationBuilder whenever a navigator's child route definitions change while retaining an existing state:

    getStateForRouteNamesChange(state, { routeNames }) {
        const routes = state.routes.filter(
            (route) => routeNames.includes(route.name)
        );
        const preloadedRoutes = state.preloadedRoutes.filter(
            (route) => routeNames.includes(route.name)
        );
        return {
            ...state,
            routeNames,
            routes,
            preloadedRoutes,
            index: Math.min(state.index, routes.length - 1),
        };
    }

    Notice that state.routes.filter(...) and state.preloadedRoutes.filter(...) are invoked directly without null or undefined guards.

    In Expensify, navigators are built using PlatformNavigatorImpl (src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx and index.native.tsx). When a navigator receives a state marked stale: false (for example, during modal transitions, screen swapping between AuthScreens and PublicScreens on HybridApp, or preserved navigator states in SearchFullscreenRouter / RightModalRouter / SplitRouter) that has routeNames but where routes is undefined or incomplete, getRehydratedState leaves stale: false states unchanged (StackRouter.tsx:205-207).

    When the navigator's screen list changes at that moment (such as when conditionally mounting screens), useNavigationBuilder directly invokes router.getStateForRouteNamesChange(state, { routeNames }). Because state.routes is undefined, state.routes.filter(...) throws an uncaught TypeError: 'filter' of undefined, causing the application to crash.

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

    We should implement a clean defensive wrapper at the platform stack navigator layer that protects all navigators across both web and native platforms:

    1. Add wrapRouterWithSafeRouteNamesChange in createPlatformStackNavigatorComponent

    In src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx and index.native.tsx:

    Define a safe router decorator:

    import Log from '@libs/Log';
    
    function wrapRouterWithSafeRouteNamesChange<TRouter extends Router<any, any>>(router: TRouter): TRouter {
        const originalGetStateForRouteNamesChange = router.getStateForRouteNamesChange;
        if (!originalGetStateForRouteNamesChange) {
            return router;
        }
    
        return {
            ...router,
            getStateForRouteNamesChange(state, options) {
                const hasRoutes = Array.isArray(state?.routes);
                const hasPreloadedRoutes = Array.isArray(state?.preloadedRoutes);
    
                if (!hasRoutes || !hasPreloadedRoutes) {
                    Log.hmmm('[Navigation] getStateForRouteNamesChange received state with undefined routes/preloadedRoutes', {
                        hasRoutes,
                        hasPreloadedRoutes,
                        routeNames: options?.routeNames,
                        state,
                    });
                }
    
                const safeState = {
                    ...state,
                    routes: hasRoutes ? state.routes : [],
                    preloadedRoutes: hasPreloadedRoutes ? state.preloadedRoutes : [],
                };
    
                return originalGetStateForRouteNamesChange(safeState, options);
            },
        };
    }

    2. Wrap createRouter in PlatformNavigatorImpl

    In PlatformNavigatorImpl (index.tsx and index.native.tsx):

    +   const safeCreateRouter = React.useCallback(
    +       (routerOptions: RouterOptions) => wrapRouterWithSafeRouteNamesChange(createRouter(routerOptions)),
    +       [createRouter],
    +   );
    
        const {
            navigation,
            state: originalState,
            descriptors,
            describe,
            NavigationContent,
        } = useNavigationBuilder<
            PlatformStackNavigationState<ParamListBase>,
            RouterOptions,
            StackActionHelpers<ParamListBase>,
            NativeStackNavigationOptions,
            NativeStackNavigationEventMap,
            PlatformStackNavigationOptions
        >(
    -       createRouter,
    +       safeCreateRouter,
            {
                id,
                children,
                screenOptions: {...defaultScreenOptions, ...screenOptions},
                screenListeners,
                initialRouteName,
                sidebarScreen,
                defaultCentralScreen,
                parentRoute,
                screenLayout,
            },
            convertToNativeNavigationOptions,
        );

    StackRouter naturally handles an empty routes array by clamping index: Math.min(state.index, routes.length - 1), safely averting the crash and allowing the rehydration pipeline to populate valid routes on the subsequent render pass, while logging diagnostic telemetry via Log.hmmm.

    What alternative solutions did you explore? (Optional)

    1. Patching @react-navigation/routers via patch-package:
      We could add a patch to patches/react-navigation/ for @react-navigation/routers modifying StackRouter.getStateForRouteNamesChange to guard (state.routes ?? []).filter(...) and (state.preloadedRoutes ?? []).filter(...). While this patches upstream directly (consistent with our other react-navigation patches in patches/react-navigation/details.md), wrapping it in createPlatformStackNavigatorComponent provides immediate application-level protection with Log.hmmm diagnostics telemetry to pinpoint any callers passing incomplete states, without having to maintain additional patch rebases on future react-navigation upgrades. Either approach (or combining both) is completely viable.
  9. melvin-bot commented on Oct 12, 2026

    @melvin-bot

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

  10. janiya2 commented on Oct 12, 2026

    @janiya2

    Proposal

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

    [Sentry: APP-KC9] Unhandled TypeError: Cannot read properties of undefined (reading 'filter') occurring during navigation state route changes in getStateForRouteNamesChange when navigating to or mounting SearchReport inside PlatformNavigatorImpl.


    What is the root cause of that problem?

    The crash occurs due to a confluence between React Navigation 7's router implementation and Expensify's preserved navigator state lifecycle:

    1. Unchecked array filter in @react-navigation/routers:
      In @react-navigation/routers (StackRouter.tsx:270-276, v7.6.4), getStateForRouteNamesChange synchronizes state when a navigator's route definition list changes:

      getStateForRouteNamesChange(state, { routeNames }) {
          const routes = state.routes.filter((route) =>
              routeNames.includes(route.name)
          );
          const preloadedRoutes = (state.preloadedRoutes ?? []).filter((route) =>
              routeNames.includes(route.name)
          );
          ...
      }

      Notice that while preloadedRoutes has a fallback guard (state.preloadedRoutes ?? []), state.routes has no guard at all.

    2. Why SearchReport triggers this in production:
      The Sentry issue explicitly identifies SearchReport on iOS hybrid app. In Expensify:

      • SearchReport resides inside RightModalNavigator (SCREENS.RIGHT_MODAL.SEARCH_REPORT, RightModalNavigator.tsx:516).
      • RightModalNavigator uses RightModalRouter (RightModalRouter.ts:15), which attempts to restore preserved state via getPreservedNavigatorState(options.parentRoute.key).
      • When a preserved state is restored with stale: false, getRehydratedState in StackRouter returns it untouched (StackRouter.tsx:205-207).
      • When screens are dynamically mounted or unmounted (e.g. SearchReport animated stacking or screen swaps), useNavigationBuilder detects that the navigator's route definitions differ from the preserved state and invokes router.getStateForRouteNamesChange(state, { routeNames }).
      • If the preserved state lacks a populated routes array (or if routes was cleared/omitted during parent state normalization), state.routes.filter(...) throws an unhandled TypeError: Cannot read properties of undefined (reading 'filter').

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

    We should implement a two-layer defense to guarantee both framework-level safety and application-level diagnostic monitoring:

    1. Universal Guard in createPlatformStackNavigatorComponent

    In src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx and src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx:

    Wrap router.getStateForRouteNamesChange in a higher-order decorator wrapRouterWithSafeRouteNamesChange. This ensures that every stack navigator across the entire app (Root, RightModal, SearchFullscreen, Split, etc.) safely normalizes missing routes and logs actionable diagnostic telemetry via Log.hmmm:

    import Log from '@libs/Log';
    
    function wrapRouterWithSafeRouteNamesChange<TRouter extends Router<any, any>>(
        router: TRouter,
        displayName: string,
    ): TRouter {
        const originalGetStateForRouteNamesChange = router.getStateForRouteNamesChange;
        if (!originalGetStateForRouteNamesChange) {
            return router;
        }
    
        return {
            ...router,
            getStateForRouteNamesChange(state, options) {
                const hasRoutes = Array.isArray(state?.routes);
                const hasPreloadedRoutes = Array.isArray(state?.preloadedRoutes);
    
                if (!hasRoutes || !hasPreloadedRoutes) {
                    Log.hmmm(`[${displayName}] getStateForRouteNamesChange received state with missing routes/preloadedRoutes`, {
                        displayName,
                        hasRoutes,
                        hasPreloadedRoutes,
                        routeNames: options?.routeNames,
                        state,
                    });
                }
    
                const safeState = {
                    ...state,
                    routes: hasRoutes ? state.routes : [],
                    preloadedRoutes: hasPreloadedRoutes ? state.preloadedRoutes : [],
                };
    
                return originalGetStateForRouteNamesChange(safeState, options);
            },
        };
    }

    In PlatformNavigatorImpl:

    +   const safeCreateRouter = React.useCallback(
    +       (routerOptions: RouterOptions) => wrapRouterWithSafeRouteNamesChange((options?.createRouter ?? StackRouter)(routerOptions), displayName),
    +       [displayName, options?.createRouter],
    +   );
    
        const {
            navigation,
            state: originalState,
            descriptors,
            describe,
            NavigationContent,
        } = useNavigationBuilder<
            PlatformStackNavigationState<ParamListBase>,
            RouterOptions,
            StackActionHelpers<ParamListBase>,
            NativeStackNavigationOptions,
            NativeStackNavigationEventMap,
            PlatformStackNavigationOptions
        >(
    -       options?.createRouter ?? StackRouter,
    +       safeCreateRouter,
            {
                id,
                children,
                screenOptions: {...defaultScreenOptions, ...screenOptions},
                screenListeners,
                initialRouteName,
                sidebarScreen,
                defaultCentralScreen,
                parentRoute,
                screenLayout,
            },
            convertToNativeNavigationOptions,
        );

    2. Defensive Upstream Patch in patches/react-navigation/

    Add a patch to @react-navigation/routers (patches/react-navigation/@react-navigation+routers+7.6.4+002+safe-routes-filter-on-route-names-change.patch) to safeguard upstream execution:

    --- a/node_modules/@react-navigation/routers/src/StackRouter.tsx
    +++ b/node_modules/@react-navigation/routers/src/StackRouter.tsx
    @@ -270,3 +270,3 @@ export function StackRouter(options: StackRouterOptions) {
    -    const routes = state.routes.filter((route) =>
    +    const routes = (state?.routes ?? []).filter((route) =>
           routeNames.includes(route.name)
         );

    3. Preserved State Sanity Guard in RightModalRouter

    In src/libs/Navigation/AppNavigator/createRightModalNavigator/RightModalRouter.ts:
    Ensure preservedState actually contains valid routes before using it as initial state:

             getInitialState({routeNames, routeParamList, routeGetIdList}: RouterConfigOptions) {
                 const preservedState = getPreservedNavigatorState(options.parentRoute.key);
    -            return preservedState ?? stackRouter.getInitialState({routeNames, routeParamList, routeGetIdList});
    +            if (preservedState && Array.isArray(preservedState.routes) && preservedState.routes.length > 0) {
    +                return preservedState;
    +            }
    +            return stackRouter.getInitialState({routeNames, routeParamList, routeGetIdList});
             },

    What alternative solutions did you explore? (Optional)

    1. Patching @react-navigation/routers only: While the upstream patch stops the crash, it fails to record Log.hmmm diagnostics to identify the origin of incomplete states reaching useNavigationBuilder. Implementing the decorator wrapper in createPlatformStackNavigatorComponent provides both crash prevention and telemetry.
    2. Guarding only inside RightModalRouter: Only protects RightModalNavigator, leaving other navigators (SearchFullscreenNavigator, SplitNavigator, OnboardingModalNavigator) vulnerable if they receive an incomplete preserved state.

    Automated Test Scenarios

    1. Unit Test in tests/unit/PlatformStackNavigationTest.ts

    import wrapRouterWithSafeRouteNamesChange from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/wrapRouterWithSafeRouteNamesChange';
    import {StackRouter} from '@react-navigation/native';
    import Log from '@libs/Log';
    
    describe('wrapRouterWithSafeRouteNamesChange', () => {
        it('does not throw when state.routes is undefined and returns a valid state', () => {
            const hmmmSpy = jest.spyOn(Log, 'hmmm').mockImplementation(() => {});
            const baseRouter = StackRouter({});
            const wrappedRouter = wrapRouterWithSafeRouteNamesChange(baseRouter, 'TestNavigator');
    
            const malformedState = {
                key: 'stack-1',
                index: 0,
                routeNames: ['Home', 'SearchReport'],
                routes: undefined as any,
                stale: false,
                type: 'stack',
            };
    
            let resultState: any;
            expect(() => {
                resultState = wrappedRouter.getStateForRouteNamesChange(malformedState, {
                    routeNames: ['Home', 'SearchReport'],
                });
            }).not.toThrow();
    
            expect(Array.isArray(resultState.routes)).toBe(true);
            expect(resultState.routes).toEqual([]);
            expect(hmmmSpy).toHaveBeenCalledTimes(1);
            expect(hmmmSpy).toHaveBeenCalledWith(
                expect.stringContaining('[TestNavigator] getStateForRouteNamesChange received state with missing routes'),
                expect.any(Object),
            );
            hmmmSpy.mockRestore();
        });
    
        it('preserves normal route filtering when state.routes is valid', () => {
            const baseRouter = StackRouter({});
            const wrappedRouter = wrapRouterWithSafeRouteNamesChange(baseRouter, 'TestNavigator');
    
            const validState = {
                key: 'stack-1',
                index: 1,
                routeNames: ['Home', 'SearchReport', 'Settings'],
                routes: [
                    {key: 'home-1', name: 'Home'},
                    {key: 'search-1', name: 'SearchReport'},
                    {key: 'old-1', name: 'ObsoleteRoute'},
                ],
                stale: false,
                type: 'stack',
            };
    
            const resultState = wrappedRouter.getStateForRouteNamesChange(validState, {
                routeNames: ['Home', 'SearchReport'],
            });
    
            expect(resultState.routes).toHaveLength(2);
            expect(resultState.routes.map((r: any) => r.name)).toEqual(['Home', 'SearchReport']);
        });
    });

    2. Unit Test in tests/unit/RightModalRouterTest.ts

    it('falls back to stackRouter.getInitialState when preservedState has undefined routes', () => {
        // Verify getInitialState returns fresh valid initial state instead of broken preservedState
    });

    Contributor Details

  11. eh2077 commented on Oct 14, 2026

    @eh2077
    Contributor

    start checking the issue

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

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions