StackRouter.js 12.1 KB
import pathToRegexp from 'path-to-regexp';

import NavigationActions from '../NavigationActions';
import createConfigGetter from './createConfigGetter';
import getScreenForRouteName from './getScreenForRouteName';
import StateUtils from '../StateUtils';
import validateRouteConfigMap from './validateRouteConfigMap';
import getScreenConfigDeprecated from './getScreenConfigDeprecated';
import invariant from '../utils/invariant';

const uniqueBaseId = `id-${Date.now()}`;
let uuidCount = 0;
function _getUuid() {
  return `${uniqueBaseId}-${uuidCount++}`;
}

function isEmpty(obj) {
  if (!obj) return true;
  for (let key in obj) {
    return false;
  }
  return true;
}

export default ((routeConfigs, stackConfig = {}) => {
  // Fail fast on invalid route definitions
  validateRouteConfigMap(routeConfigs);

  const childRouters = {};
  const routeNames = Object.keys(routeConfigs);

  // Loop through routes and find child routers
  routeNames.forEach(routeName => {
    const screen = getScreenForRouteName(routeConfigs, routeName);
    if (screen && screen.router) {
      // If it has a router it's a navigator.
      childRouters[routeName] = screen.router;
    } else {
      // If it doesn't have router it's an ordinary React component.
      childRouters[routeName] = null;
    }
  });

  const { initialRouteParams } = stackConfig;

  const initialRouteName = stackConfig.initialRouteName || routeNames[0];

  const initialChildRouter = childRouters[initialRouteName];
  const paths = stackConfig.paths || {};

  // Build paths for each route
  routeNames.forEach(routeName => {
    let pathPattern = paths[routeName] || routeConfigs[routeName].path;
    const matchExact = !!pathPattern && !childRouters[routeName];
    if (typeof pathPattern !== 'string') {
      pathPattern = routeName;
    }
    const keys = [];
    let re = pathToRegexp(pathPattern, keys);
    if (!matchExact) {
      const wildcardRe = pathToRegexp(`${pathPattern}/*`, keys);
      re = new RegExp(`(?:${re.source})|(?:${wildcardRe.source})`);
    }
    /* $FlowFixMe */
    paths[routeName] = { re, keys, toPath: pathToRegexp.compile(pathPattern) };
  });

  return {
    getComponentForState(state) {
      const activeChildRoute = state.routes[state.index];
      const { routeName } = activeChildRoute;
      if (childRouters[routeName]) {
        return childRouters[routeName].getComponentForState(activeChildRoute);
      }
      return getScreenForRouteName(routeConfigs, routeName);
    },

    getComponentForRouteName(routeName) {
      return getScreenForRouteName(routeConfigs, routeName);
    },

    getStateForAction(action, state) {
      // Set up the initial state if needed
      if (!state) {
        let route = {};
        if (action.type === NavigationActions.NAVIGATE && childRouters[action.routeName] !== undefined) {
          return {
            index: 0,
            routes: [{
              ...action,
              type: undefined,
              key: `Init-${_getUuid()}`
            }]
          };
        }
        if (initialChildRouter) {
          route = initialChildRouter.getStateForAction(NavigationActions.navigate({
            routeName: initialRouteName,
            params: initialRouteParams
          }));
        }
        const params = (route.params || action.params || initialRouteParams) && {
          ...(route.params || {}),
          ...(action.params || {}),
          ...(initialRouteParams || {})
        };
        route = {
          ...route,
          routeName: initialRouteName,
          key: `Init-${_getUuid()}`,
          ...(params ? { params } : {})
        };
        // eslint-disable-next-line no-param-reassign
        state = {
          index: 0,
          routes: [route]
        };
      }

      // Check if a child scene wants to handle the action as long as it is not a reset to the root stack
      if (action.type !== NavigationActions.RESET || action.key !== null) {
        const keyIndex = action.key ? StateUtils.indexOf(state, action.key) : -1;
        const childIndex = keyIndex >= 0 ? keyIndex : state.index;
        const childRoute = state.routes[childIndex];
        invariant(childRoute, `StateUtils erroneously thought index ${childIndex} exists`);
        const childRouter = childRouters[childRoute.routeName];
        if (childRouter) {
          const route = childRouter.getStateForAction(action, childRoute);
          if (route === null) {
            return state;
          }
          if (route && route !== childRoute) {
            return StateUtils.replaceAt(state, childRoute.key, route);
          }
        }
      }

      // Handle explicit push navigation action
      if (action.type === NavigationActions.NAVIGATE && childRouters[action.routeName] !== undefined) {
        const childRouter = childRouters[action.routeName];
        let route;
        if (childRouter) {
          const childAction = action.action || NavigationActions.init({ params: action.params });
          route = {
            params: action.params,
            ...childRouter.getStateForAction(childAction),
            key: _getUuid(),
            routeName: action.routeName
          };
        } else {
          route = {
            params: action.params,
            key: _getUuid(),
            routeName: action.routeName
          };
        }
        return StateUtils.push(state, route);
      }

      // Handle navigation to other child routers that are not yet pushed
      if (action.type === NavigationActions.NAVIGATE) {
        const childRouterNames = Object.keys(childRouters);
        for (let i = 0; i < childRouterNames.length; i++) {
          const childRouterName = childRouterNames[i];
          const childRouter = childRouters[childRouterName];
          if (childRouter) {
            // For each child router, start with a blank state
            const initChildRoute = childRouter.getStateForAction(NavigationActions.init());
            // Then check to see if the router handles our navigate action
            const navigatedChildRoute = childRouter.getStateForAction(action, initChildRoute);
            let routeToPush = null;
            if (navigatedChildRoute === null) {
              // Push the route if the router has 'handled' the action and returned null
              routeToPush = initChildRoute;
            } else if (navigatedChildRoute !== initChildRoute) {
              // Push the route if the state has changed in response to this navigation
              routeToPush = navigatedChildRoute;
            }
            if (routeToPush) {
              return StateUtils.push(state, {
                ...routeToPush,
                key: _getUuid(),
                routeName: childRouterName
              });
            }
          }
        }
      }

      if (action.type === NavigationActions.SET_PARAMS) {
        const key = action.key;
        const lastRoute = state.routes.find(route => route.key === key);
        if (lastRoute) {
          const params = {
            ...lastRoute.params,
            ...action.params
          };
          const routes = [...state.routes];
          routes[state.routes.indexOf(lastRoute)] = {
            ...lastRoute,
            params
          };
          return {
            ...state,
            routes
          };
        }
      }

      if (action.type === NavigationActions.RESET) {
        const resetAction = action;

        return {
          ...state,
          routes: resetAction.actions.map(childAction => {
            const router = childRouters[childAction.routeName];
            if (router) {
              return {
                ...childAction,
                ...router.getStateForAction(childAction),
                routeName: childAction.routeName,
                key: _getUuid()
              };
            }
            const route = {
              ...childAction,
              key: _getUuid()
            };
            delete route.type;
            return route;
          }),
          index: action.index
        };
      }

      if (action.type === NavigationActions.BACK) {
        const key = action.key;
        let backRouteIndex = null;
        if (key) {
          const backRoute = state.routes.find(route => route.key === key);
          /* $FlowFixMe */
          backRouteIndex = state.routes.indexOf(backRoute);
        }
        if (backRouteIndex == null) {
          return StateUtils.pop(state);
        }
        if (backRouteIndex > 0) {
          return {
            ...state,
            routes: state.routes.slice(0, backRouteIndex),
            index: backRouteIndex - 1
          };
        }
      }
      return state;
    },

    getPathAndParamsForState(state) {
      const route = state.routes[state.index];
      const routeName = route.routeName;
      const screen = getScreenForRouteName(routeConfigs, routeName);
      /* $FlowFixMe */
      const subPath = paths[routeName].toPath(route.params);
      let path = subPath;
      let params = route.params;
      if (screen && screen.router) {
        // $FlowFixMe there's no way type the specific shape of the nav state
        const stateRoute = route;
        // If it has a router it's a navigator.
        // If it doesn't have router it's an ordinary React component.
        const child = screen.router.getPathAndParamsForState(stateRoute);
        path = subPath ? `${subPath}/${child.path}` : child.path;
        params = child.params ? { ...params, ...child.params } : params;
      }
      return {
        path,
        params
      };
    },

    getActionForPathAndParams(pathToResolve, inputParams) {
      // If the path is empty (null or empty string)
      // just return the initial route action
      if (!pathToResolve) {
        return NavigationActions.navigate({
          routeName: initialRouteName
        });
      }

      const [pathNameToResolve, queryString] = pathToResolve.split('?');

      // Attempt to match `pathNameToResolve` with a route in this router's
      // routeConfigs
      let matchedRouteName;
      let pathMatch;
      let pathMatchKeys;

      // eslint-disable-next-line no-restricted-syntax
      for (const [routeName, path] of Object.entries(paths)) {
        /* $FlowFixMe */
        const { re, keys } = path;
        pathMatch = re.exec(pathNameToResolve);
        if (pathMatch && pathMatch.length) {
          pathMatchKeys = keys;
          matchedRouteName = routeName;
          break;
        }
      }

      // We didn't match -- return null
      if (!matchedRouteName) {
        return null;
      }

      // Determine nested actions:
      // If our matched route for this router is a child router,
      // get the action for the path AFTER the matched path for this
      // router
      let nestedAction;
      let nestedQueryString = queryString ? '?' + queryString : '';
      if (childRouters[matchedRouteName]) {
        nestedAction = childRouters[matchedRouteName].getActionForPathAndParams(
        /* $FlowFixMe */
        pathMatch.slice(pathMatchKeys.length).join('/') + nestedQueryString);
      }

      // reduce the items of the query string. any query params may
      // be overridden by path params
      const queryParams = !isEmpty(inputParams) ? inputParams : (queryString || '').split('&').reduce((result, item) => {
        if (item !== '') {
          const nextResult = result || {};
          const [key, value] = item.split('=');
          nextResult[key] = value;
          return nextResult;
        }
        return result;
      }, null);

      // reduce the matched pieces of the path into the params
      // of the route. `params` is null if there are no params.
      /* $FlowFixMe */
      const params = pathMatch.slice(1).reduce((result, matchResult, i) => {
        const key = pathMatchKeys[i];
        if (key.asterisk || !key) {
          return result;
        }
        const nextResult = result || {};
        const paramName = key.name;
        nextResult[paramName] = matchResult;
        return nextResult;
      }, queryParams);

      return NavigationActions.navigate({
        routeName: matchedRouteName,
        ...(params ? { params } : {}),
        ...(nestedAction ? { action: nestedAction } : {})
      });
    },

    getScreenOptions: createConfigGetter(routeConfigs, stackConfig.navigationOptions),

    getScreenConfig: getScreenConfigDeprecated
  };
});