react-nativereact-navigationdeep-linkingstack-navigatorreact-native-deep-linking

react-navigation deep linking to sub-screens of a StackNavigator - but initialRouteName-screen is missing from stack


I have a React Native app, where it is possible to deep link into content, from push notifications, external links etc.

When the app is already running, it works as expected, and the app goes to the screen expected from the URL, by adding that screen to the navigation stack of the relevant navigation tab.

But when the app is not running, and the deep links intiate the lauch, the screen is added as the root element of the stack of the NotificationsNavigator.

This means that the user cannot go back to the overview of notifications, and when selecting the Notifications tab in the bottom menu, the initial screen from the deeplink is shown, not the intialRouteName.

It should ALWAYS push the NotificationsScreen as the first element of the stack in NotificationsNavigator, before pushing the route that has been configured in the deeplinks configuration.

How to achieve that? I already tried lazy: false on the TabNavigator, no change. I also tried detachInactiveScreens={false} on my NotificationsNavigator with no luck.

What is the trick?

The URLs I use are like

RootNavigator.tsx:


const deepLinksConf = {
  screens: {
    loginOIDCWait: {
      path: 'login/oidc',
    },
    frontPageNavigator: {
      screens: {
        newsArticle: {
          path: 'frontpage/news/article',
          parse: {
            // This is done because `react-navigation` for some reason does not decode URL query parameters.
            articleUrl: (articleUrl: string) => decodeURIComponent(articleUrl),
          },
        },
        bulletinBoard: 'frontpage/bulletinboard',
        bulletinBoardPost: 'frontpage/bulletinboard/post',
        enquiriesEnquiryDetails: 'frontpage/enquiry',
      },
    },
    notificationsNavigator: {
      screens: {
        newsArticle: {
          path: 'notifications/news/article',
          parse: {
            // This is done because `react-navigation` for some reason does not decode URL query parameters.
            articleUrl: (articleUrl: string) => decodeURIComponent(articleUrl),
          },
        },
        bulletinBoard: 'notifications/bulletinboard',
        bulletinBoardPost: 'notifications/bulletinboard/post',
        enquiriesEnquiryDetails: 'notifications/enquiry',
      },
    },
  },
}

// https://medium.com/cybermonkey/deep-linking-push-notifications-with-react-navigation-5fce260ccca2
const getLinkingOptions: (notificationsStore: INotificationsStore) => LinkingOptions = (notificationsStore) => ({
  prefixes: [`${URL_SCHEME}://`],
  config: deepLinksConf,
  async getInitialURL() {
    // Check if app was opened from a deep link
    const url = await Linking.getInitialURL()

    if (url !== null) {
      return url
    }

    // Check if there is an initial firebase notification
    const message = await messaging().getInitialNotification()

    // Get deep link from data
    // if this is undefined, the app will open the default/home page
    return message?.data?.link
  },
  subscribe(listener) {
    const onReceiveURL = ({ url }: { url: string }) => listener(url)

    // Listen to incoming links from deep linking
    const urlReceiveEventListener = Linking.addEventListener('url', onReceiveURL)

    // Listen to firebase push notifications
    const unsubscribeNotification = messaging().onNotificationOpenedApp((message) => {
      const url = message?.data?.link
      if (message?.data?.notificationId !== undefined) {
        notificationsStore.markAsReadById(message.data.notificationId)
      }

      if (url !== undefined) {
        // Any custom logic to check whether the URL needs to be handled

        // Call the listener to let React Navigation handle the URL
        listener(url)
      }
    })

    return () => {
      // Clean up the event listeners
      urlReceiveEventListener.remove()
      unsubscribeNotification()
    }
  },
})

const RootStack = observer(() => {
  const { authStore, entitiesStore, systemStore } = useStores()
  const { notificationsStore } = entitiesStore

  if (systemStore.isShowingPlacard) {
    return <InitialNavigator />
  }
  switch (authStore.authState) {
    case 'pending':
      // if auth state is not determined yet, because a biometric prompt is pending,
      // then show the initial navigator
      return <InitialNavigator />

    case 'unauthorized':
      // if unauthorized then show auth navigator
      return <AuthNavigator />

    case 'authorized':
      // Check if user has already completed onboarding
      if (!authStore.hasCompletedOnboarding()) {
        return <OnboardingNavigator />
      }
      // if authorized show the tab navigator
      return <TabNavigator hasNewNotifications={notificationsStore.unreadCount > 0} />

    default:
      return <InitialNavigator />
  }
})

export const RootNavigator = React.forwardRef<
  NavigationContainerRef,
  Partial<React.ComponentProps<typeof NavigationContainer>>
>((props, ref) => {
  const { entitiesStore } = useStores()
  const { notificationsStore } = entitiesStore
  return (
    <NavigationContainer {...props} ref={ref} linking={getLinkingOptions(notificationsStore)}>
      <RootStack />
    </NavigationContainer>
  )
})

RootNavigator.displayName = 'RootNavigator'

NotificationsNavigator.tsx

type ParamList = {
  notifications: undefined
  settings: { mode: 'onboarding' | 'default' }
  newsArticle: INewsArticle
  bulletinBoardPost: { boardId: string; postId: string; shouldAddComment?: boolean }
  enquiriesEnquiryDetails: { enquiryId: string }
}

const Stack = createStackNavigator<ParamList>()

export function getNotificationsTabBarStyle(
  route: Partial<Route<string>> & {
    state?: PartialState<NavigationState>
  }
): StyleProp<ViewStyle> {
  const routeName = getFocusedRouteNameFromRoute(route)
  switch (routeName) {
    case 'newsArticle':
    case 'settings':
    case 'bulletinBoardPost':
    case 'enquiriesEnquiryDetails':
      return { display: 'none' }
    default:
      return {}
  }
}

export type NotificationsNavigationProps<K extends Extract<keyof ParamList, string>> = {
  route: RouteProp<ParamList, K>
  navigation: StackNavigationProp<ParamList, K>
}

export const NotificationsNavigator = () => (
  <Stack.Navigator
    initialRouteName='notifications'
    screenOptions={{
      headerShown: true,
      headerBackTitle: '',
      gestureEnabled: true,
    }}
  >
    <Stack.Screen
      component={NotificationsScreen}
      name='notifications'
      options={{
        ...defaultScreenStyle,
        title: t('notificationsScreen.title'),
      }}
    />
    <Stack.Screen
      component={NewsArticleScreen}
      name='newsArticle'
      options={{
        ...defaultScreenStyle,
        headerShown: false,
      }}
    />
    <Stack.Screen
      component={SettingsScreen}
      name='settings'
      options={{
        ...defaultScreenStyle,
        title: t('tabs.settings'),
      }}
    />
    <Stack.Screen
      component={BulletinBoardPostScreen}
      name='bulletinBoardPost'
      options={{ ...defaultScreenStyle, title: '' }}
    />
    <Stack.Screen
      component={EnquiriesEnquiryDetailsScreen}
      name='enquiriesEnquiryDetails'
      options={{ ...defaultScreenStyle }}
    />
  </Stack.Navigator>
)

TabNavigator.tsx

export type TabNavigationProps<K extends Extract<keyof TabParamList, string>> = {
  route: RouteProp<TabParamList, K>
  navigation: BottomTabNavigationProp<TabParamList>
}

const Tab = createBottomTabNavigator<TabParamList>()

type Props = {
  hasNewNotifications: boolean
}

export const TabNavigator = ({ hasNewNotifications }: Props) => (
  <Tab.Navigator
    screenOptions={{
      headerShown: false,
      tabBarActiveTintColor: color.tint,
      tabBarInactiveTintColor: color.disabled,
      tabBarHideOnKeyboard: true,
      tabBarActiveBackgroundColor: color.elementBackground,
      tabBarInactiveBackgroundColor: color.elementBackground,
    }}
    tabBar={(props) => (
      <TabBar
        maxItems={5}
        moreIcon={({ color, size }) => (
          <View style={[{ width: size, height: size }, styles.tabBarIconContainer]}>
            <Image source={require('../../assets/png/icons/tab-more.png')} style={{ tintColor: color }} />
            {hasNewNotifications && <View style={notificationTabStyles.indicatorDot} />}
          </View>
        )}
        moreTitle={t('tabs.more')}
        {...props}
      />
    )}
  >
    <Tab.Screen
      component={FrontPageNavigator}
      name='frontPageNavigator'
      options={({ route }) => ({
        tabBarStyle: getFrontPageTabBarStyle(route),
        tabBarIcon: ({ color, size }) => (
          <View style={[{ width: size, height: size }, styles.tabBarIconContainer]}>
            <Image source={require('../../assets/png/icons/tab-front-page.png')} style={{ tintColor: color }} />
          </View>
        ),
        title: t('tabs.frontPage'),
      })}
    />
    <Tab.Screen
      component={NewsNavigator}
      name='newsNavigator'
      options={{
        tabBarIcon: ({ color, size }) => (
          <View style={[{ width: size, height: size }, styles.tabBarIconContainer]}>
            <Image source={require('../../assets/png/icons/tab-news.png')} style={{ tintColor: color }} />
          </View>
        ),
        title: t('tabs.news'),
      }}
    />
    <Tab.Screen
      component={BulletinBoardNavigator}
      name='bulletinBoardNavigator'
      options={({ route }) => ({
        tabBarStyle: getBulletinBoardTabBarStyle(route),
        tabBarIcon: ({ color, size }) => (
          <View style={[{ width: size, height: size }, styles.tabBarIconContainer]}>
            <Image source={require('../../assets/png/icons/tab-bulletin-board.png')} style={{ tintColor: color }} />
          </View>
        ),
        title: t('tabs.bulletinBoard'),
      })}
    />
    <Tab.Screen
      component={EnquiriesNavigator}
      name='enquiriesNavigator'
      options={({ route }) => ({
        tabBarStyle: getEnquiriesTabBarStyle(route),
        tabBarIcon: ({ color, size }) => (
          <View style={[{ width: size, height: size }, styles.tabBarIconContainer]}>
            <Image source={require('../../assets/png/icons/tab-enquiries.png')} style={{ tintColor: color }} />
          </View>
        ),
        title: t('tabs.enquiries'),
      })}
    />
    <Tab.Screen
      component={NotificationsNavigator}
      name='notificationsNavigator'
      options={({ route }) => ({
        tabBarStyle: getNotificationsTabBarStyle(route),
        tabBarIcon: ({ color, focused, size }) => (
          <NotificationsTabIcon color={color} focused={focused} hasNewNotifications={hasNewNotifications} size={size} />
        ),
        title: t('tabs.notifications'),
      })}
    />
    <Tab.Screen
      component={UserProfileScreen}
      initialParams={{ mode: 'default' }}
      name='userProfile'
      options={{
        tabBarIcon: ({ color, size }) => (
          <View style={[{ width: size, height: size }, styles.tabBarIconContainer]}>
            <Image source={require('../../assets/png/icons/tab-user-profile.png')} style={{ tintColor: color }} />
          </View>
        ),
        title: t('tabs.userProfile'),
      }}
    />
    <Tab.Screen
      component={SettingsNavigator}
      name='settingsNavigator'
      options={{
        tabBarIcon: ({ color, size }) => (
          <View style={[{ width: size, height: size }, styles.tabBarIconContainer]}>
            <Image
              source={require('../../assets/png/icons/tab-settings.png')}
              style={{ height: size, tintColor: color, width: size }}
            />
          </View>
        ),
        title: t('tabs.settings'),
      }}
    />
  </Tab.Navigator>
)

const styles = StyleSheet.create({
  tabBarIconContainer: {
    alignItems: 'center',
    justifyContent: 'center',
  },
})


Solution

  • UPDATE

    There is a simpler solution to this: Add an initialRouteName to the navigator in the deep links config.

        notificationsNavigator: {
          initialRouteName: 'notifications', // <---- This
          screens: {
            newsArticle: 'notifications/news/article',
            bulletinBoard: 'notifications/bulletinboard',
            bulletinBoardPost: 'notifications/bulletinboard/post',
            enquiriesEnquiryDetails: 'notifications/enquiry',
          },
    

    https://reactnavigation.org/docs/configuring-links/#rendering-an-initial-route


    I fixed this by adding a screenListeners prop to my StackNavigator and check on focus events, whether the notifications screen is the root element or not, and then manipulate the routes if necessary.

    I first tried doing this in a state event but my manipulation of the state did not take effect when launching the app from a deep link when it was not in background already. This solution seems to work well in all cases:

     <Stack.Navigator
        initialRouteName='notifications'
        screenListeners={({ navigation }) => ({
          focus() {
            const { routes } = navigation.getState()
            // Check if NotificationsScreen is not loaded
            if (routes[0].name !== 'notifications') {
              // @ts-expect-error poor typing of react-navigation
              navigation.dispatch((state) => {
                // Add the notifications route to top of the stack
                const routes = [
                  {
                    name: 'notifications',
                  },
                  ...state.routes,
                ]
                return CommonActions.reset({
                  ...state,
                  routes,
                  index: routes.length - 1,
                })
              })
            }
          },
        })}
        screenOptions={{
          headerShown: true,
          headerBackTitle: '',
          gestureEnabled: true,
        }}
      >