diff --git a/package.json b/package.json index 4d9924c..6b1a195 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "create-react-context": "^0.2.1", "hoist-non-react-statics": "^2.2.0", "path-to-regexp": "^1.7.0", + "query-string": "^6.1.0", "react-lifecycles-compat": "^3", "react-native-safe-area-view": "^0.8.0", "react-navigation-deprecated-tab-navigator": "1.3.0", diff --git a/src/createNavigationContainer.js b/src/createNavigationContainer.js index ac5e8ec..337d66a 100644 --- a/src/createNavigationContainer.js +++ b/src/createNavigationContainer.js @@ -6,6 +6,7 @@ import NavigationActions from './NavigationActions'; import getNavigation from './getNavigation'; import invariant from './utils/invariant'; import docsUrl from './utils/docsUrl'; +import { urlToPathAndParams } from './routers/pathUtils'; function isStateful(props) { return !props.navigation; @@ -128,23 +129,8 @@ export default function createNavigationContainer(Component) { } } - _urlToPathAndParams(url) { - const params = {}; - const delimiter = this.props.uriPrefix || '://'; - let path = url.split(delimiter)[1]; - if (typeof path === 'undefined') { - path = url; - } else if (path === '') { - path = '/'; - } - return { - path, - params, - }; - } - _handleOpenURL = ({ url }) => { - const parsedUrl = this._urlToPathAndParams(url); + const parsedUrl = urlToPathAndParams(url, this.props.uriPrefix); if (parsedUrl) { const { path, params } = parsedUrl; const action = Component.router.getActionForPathAndParams(path, params); @@ -213,11 +199,11 @@ export default function createNavigationContainer(Component) { Linking.addEventListener('url', this._handleOpenURL); // Pull out anything that can impact state - const { persistenceKey } = this.props; + const { persistenceKey, uriPrefix } = this.props; const startupStateJSON = persistenceKey && (await AsyncStorage.getItem(persistenceKey)); const url = await Linking.getInitialURL(); - const parsedUrl = url && this._urlToPathAndParams(url); + const parsedUrl = url && urlToPathAndParams(url, uriPrefix); // Initialize state. This must be done *after* any async code // so we don't end up with a different value for this.state.nav diff --git a/src/routers/StackRouter.js b/src/routers/StackRouter.js index 6e6703b..0720bd2 100644 --- a/src/routers/StackRouter.js +++ b/src/routers/StackRouter.js @@ -1,5 +1,3 @@ -import pathToRegexp from 'path-to-regexp'; - import NavigationActions from '../NavigationActions'; import StackActions from './StackActions'; import createConfigGetter from './createConfigGetter'; @@ -8,14 +6,7 @@ import StateUtils from '../StateUtils'; import validateRouteConfigMap from './validateRouteConfigMap'; import invariant from '../utils/invariant'; import { generateKey } from './KeyGenerator'; - -function isEmpty(obj) { - if (!obj) return true; - for (let key in obj) { - return false; - } - return true; -} +import { createPathParser } from './pathUtils'; function behavesLikePushAction(action) { return ( @@ -56,8 +47,6 @@ export default (routeConfigs, stackConfig = {}) => { const initialRouteName = stackConfig.initialRouteName || routeNames[0]; const initialChildRouter = childRouters[initialRouteName]; - const pathsByRouteNames = { ...stackConfig.paths } || {}; - let paths = []; function getInitialState(action) { let route = {}; @@ -115,37 +104,16 @@ export default (routeConfigs, stackConfig = {}) => { }; } - // Build paths for each route - routeNames.forEach(routeName => { - let pathPattern = - pathsByRouteNames[routeName] || routeConfigs[routeName].path; - let matchExact = !!pathPattern && !childRouters[routeName]; - if (pathPattern === undefined) { - pathPattern = routeName; - } - const keys = []; - let re, toPath, priority; - if (typeof pathPattern === 'string') { - // pathPattern may be either a string or a regexp object according to path-to-regexp docs. - re = pathToRegexp(pathPattern, keys); - toPath = pathToRegexp.compile(pathPattern); - priority = 0; - } else { - // for wildcard match - re = pathToRegexp('*', keys); - toPath = () => ''; - matchExact = true; - priority = -1; - } - if (!matchExact) { - const wildcardRe = pathToRegexp(`${pathPattern}/*`, keys); - re = new RegExp(`(?:${re.source})|(?:${wildcardRe.source})`); - } - pathsByRouteNames[routeName] = { re, keys, toPath, priority }; - }); - - paths = Object.entries(pathsByRouteNames); - paths.sort((a, b) => b[1].priority - a[1].priority); + const { + getPathAndParamsForRoute, + getActionForPathAndParams, + } = createPathParser( + childRouters, + routeConfigs, + stackConfig.paths, + initialRouteName, + initialRouteParams + ); return { childRouters, @@ -559,121 +527,11 @@ export default (routeConfigs, stackConfig = {}) => { getPathAndParamsForState(state) { const route = state.routes[state.index]; - const routeName = route.routeName; - const screen = getScreenForRouteName(routeConfigs, routeName); - const subPath = pathsByRouteNames[routeName].toPath(route.params); - let path = subPath; - let params = route.params; - if (screen && screen.router) { - 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, - }; + return getPathAndParamsForRoute(route); }, - 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, - params: inputParams, - }); - } - - 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 paths) { - 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) { - // If the path is empty (null or empty string) - // just return the initial route action - if (!pathToResolve) { - return NavigationActions.navigate({ - routeName: initialRouteName, - }); - } - 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( - pathMatch.slice(pathMatchKeys.length).join('/') + nestedQueryString - ); - if (!nestedAction) { - return null; - } - } - - // 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. - const params = pathMatch.slice(1).reduce((result, matchResult, i) => { - const key = pathMatchKeys[i]; - if (key.asterisk || !key) { - return result; - } - const nextResult = result || inputParams || {}; - const paramName = key.name; - - let decodedMatchResult; - try { - decodedMatchResult = decodeURIComponent(matchResult); - } catch (e) { - // ignore `URIError: malformed URI` - } - - nextResult[paramName] = decodedMatchResult || matchResult; - return nextResult; - }, queryParams); - - return NavigationActions.navigate({ - routeName: matchedRouteName, - ...(params ? { params } : {}), - ...(nestedAction ? { action: nestedAction } : {}), - }); + getActionForPathAndParams(path, params) { + return getActionForPathAndParams(path, params); }, getScreenOptions: createConfigGetter( diff --git a/src/routers/SwitchRouter.js b/src/routers/SwitchRouter.js index f877c83..0d7cb0a 100644 --- a/src/routers/SwitchRouter.js +++ b/src/routers/SwitchRouter.js @@ -5,6 +5,7 @@ import createConfigGetter from './createConfigGetter'; import NavigationActions from '../NavigationActions'; import StackActions from './StackActions'; import validateRouteConfigMap from './validateRouteConfigMap'; +import { createPathParser } from './pathUtils'; const defaultActionCreators = (route, navStateKey) => ({}); @@ -21,7 +22,7 @@ export default (routeConfigs, config = {}) => { validateRouteConfigMap(routeConfigs); const order = config.order || Object.keys(routeConfigs); - const paths = config.paths || {}; + const getCustomActionCreators = config.getCustomActionCreators || defaultActionCreators; @@ -36,16 +37,24 @@ export default (routeConfigs, config = {}) => { const childRouters = {}; order.forEach(routeName => { const routeConfig = routeConfigs[routeName]; - if (!paths[routeName]) { - paths[routeName] = - typeof routeConfig.path === 'string' ? routeConfig.path : routeName; - } childRouters[routeName] = null; const screen = getScreenForRouteName(routeConfigs, routeName); if (screen.router) { childRouters[routeName] = screen.router; } }); + + const { + getPathAndParamsForRoute, + getActionForPathAndParams, + } = createPathParser( + childRouters, + routeConfigs, + config.paths, + initialRouteName, + initialRouteParams + ); + if (initialRouteIndex === -1) { throw new Error( `Invalid initialRouteName '${initialRouteName}'.` + @@ -309,73 +318,11 @@ export default (routeConfigs, config = {}) => { getPathAndParamsForState(state) { const route = state.routes[state.index]; - const routeName = order[state.index]; - const subPath = paths[routeName]; - const screen = getScreenForRouteName(routeConfigs, routeName); - let path = subPath; - let params = route.params; - if (screen && screen.router) { - 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, - }; + return getPathAndParamsForRoute(route); }, - /** - * Gets an optional action, based on a relative path and query params. - * - * This will return null if there is no action matched - */ getActionForPathAndParams(path, params) { - if (!path) { - return NavigationActions.navigate({ - routeName: initialRouteName, - params, - }); - } - return ( - order - .map(childId => { - const parts = path.split('/'); - const pathToTest = paths[childId]; - const partsInTestPath = pathToTest.split('/').length; - const pathPartsToTest = parts.slice(0, partsInTestPath).join('/'); - if (pathPartsToTest === pathToTest) { - const childRouter = childRouters[childId]; - const action = NavigationActions.navigate({ - routeName: childId, - }); - if (childRouter && childRouter.getActionForPathAndParams) { - action.action = childRouter.getActionForPathAndParams( - parts.slice(partsInTestPath).join('/'), - params - ); - } - if (params) { - action.params = params; - } - return action; - } - return null; - }) - .find(action => !!action) || - order - .map(childId => { - const childRouter = childRouters[childId]; - return ( - childRouter && childRouter.getActionForPathAndParams(path, params) - ); - }) - .find(action => !!action) || - null - ); + return getActionForPathAndParams(path, params); }, getScreenOptions: createConfigGetter( diff --git a/src/routers/__tests__/PathHandling-test.js b/src/routers/__tests__/PathHandling-test.js new file mode 100644 index 0000000..56b8a05 --- /dev/null +++ b/src/routers/__tests__/PathHandling-test.js @@ -0,0 +1,299 @@ +/* eslint no-shadow:0, react/no-multi-comp:0, react/display-name:0 */ + +import React from 'react'; + +import SwitchRouter from '../SwitchRouter'; +import StackRouter from '../StackRouter'; +import StackActions from '../StackActions'; +import NavigationActions from '../../NavigationActions'; +import { urlToPathAndParams } from '../pathUtils'; +import { _TESTING_ONLY_normalize_keys } from '../KeyGenerator'; + +beforeEach(() => { + _TESTING_ONLY_normalize_keys(); +}); + +const ListScreen = () =>
; + +const ProfileNavigator = () =>
; +ProfileNavigator.router = StackRouter({ + list: { + path: 'list/:id', + screen: ListScreen, + }, +}); + +const MainNavigator = () =>
; +MainNavigator.router = StackRouter({ + profile: { + path: 'p/:id', + screen: ProfileNavigator, + }, +}); + +const LoginScreen = () =>
; + +const AuthNavigator = () =>
; +AuthNavigator.router = StackRouter({ + login: { + screen: LoginScreen, + }, +}); + +const BarScreen = () =>
; + +class FooNavigator extends React.Component { + static router = StackRouter({ + bar: { + path: 'b/:barThing', + screen: BarScreen, + }, + }); + render() { + return
; + } +} + +const PersonScreen = () =>
; + +const performRouterTest = createTestRouter => { + const testRouter = createTestRouter({ + main: { + screen: MainNavigator, + }, + baz: { + path: null, + screen: FooNavigator, + }, + auth: { + screen: AuthNavigator, + }, + person: { + path: 'people/:id', + screen: PersonScreen, + }, + foo: { + path: 'fo/:fooThing', + screen: FooNavigator, + }, + }); + + test('Handles empty URIs', () => { + const router = createTestRouter( + { + Foo: { + screen: () =>
, + }, + Bar: { + screen: () =>
, + }, + }, + { initialRouteName: 'Bar', initialRouteParams: { foo: 42 } } + ); + const action = router.getActionForPathAndParams(''); + expect(action).toEqual({ + type: NavigationActions.NAVIGATE, + routeName: 'Bar', + params: { foo: 42 }, + }); + const state = router.getStateForAction(action); + expect(state.routes[state.index]).toEqual( + expect.objectContaining({ + routeName: 'Bar', + params: { foo: 42 }, + }) + ); + }); + + test('Gets deep path with pure wildcard match', () => { + const ScreenA = () =>
; + const ScreenB = () =>
; + const ScreenC = () =>
; + ScreenA.router = createTestRouter({ + Boo: { path: 'boo', screen: ScreenC }, + Baz: { path: 'baz/:bazId', screen: ScreenB }, + }); + ScreenC.router = createTestRouter({ + Boo2: { path: '', screen: ScreenB }, + }); + const router = createTestRouter({ + Foo: { + path: null, + screen: ScreenA, + }, + Bar: { + screen: ScreenB, + }, + }); + + { + const state = { + index: 0, + routes: [ + { + index: 1, + key: 'Foo', + routeName: 'Foo', + params: { + id: '123', + }, + routes: [ + { + index: 0, + key: 'Boo', + routeName: 'Boo', + routes: [{ key: 'Boo2', routeName: 'Boo2' }], + }, + { key: 'Baz', routeName: 'Baz', params: { bazId: '321' } }, + ], + }, + { key: 'Bar', routeName: 'Bar' }, + ], + }; + const { path, params } = router.getPathAndParamsForState(state); + expect(path).toEqual('baz/321'); + expect(params.id).toEqual('123'); + expect(params.bazId).toEqual('321'); + } + + { + const state = { + index: 0, + routes: [ + { + index: 0, + key: 'Foo', + routeName: 'Foo', + params: { + id: '123', + }, + routes: [ + { + index: 0, + key: 'Boo', + routeName: 'Boo', + routes: [{ key: 'Boo2', routeName: 'Boo2' }], + }, + { key: 'Baz', routeName: 'Baz', params: { bazId: '321' } }, + ], + }, + { key: 'Bar', routeName: 'Bar' }, + ], + }; + const { path, params } = router.getPathAndParamsForState(state); + expect(path).toEqual('boo'); + expect(params).toEqual({ id: '123' }); + } + }); + + test('URI encoded string get passed to deep link', () => { + const uri = 'people/2018%2F02%2F07'; + const action = testRouter.getActionForPathAndParams(uri); + expect(action).toEqual({ + routeName: 'person', + params: { + id: '2018/02/07', + }, + type: NavigationActions.NAVIGATE, + }); + + const malformedUri = 'people/%E0%A4%A'; + const action2 = testRouter.getActionForPathAndParams(malformedUri); + expect(action2).toEqual({ + routeName: 'person', + params: { + id: '%E0%A4%A', + }, + type: NavigationActions.NAVIGATE, + }); + }); + + test('Querystring params get passed to nested deep link', () => { + const action = testRouter.getActionForPathAndParams( + 'main/p/4/list/10259959195', + { code: 'test', foo: 'bar' } + ); + expect(action).toEqual({ + type: NavigationActions.NAVIGATE, + routeName: 'main', + params: { + code: 'test', + foo: 'bar', + }, + action: { + type: NavigationActions.NAVIGATE, + routeName: 'profile', + params: { + id: '4', + code: 'test', + foo: 'bar', + }, + action: { + type: NavigationActions.NAVIGATE, + routeName: 'list', + params: { + id: '10259959195', + code: 'test', + foo: 'bar', + }, + }, + }, + }); + + const action2 = testRouter.getActionForPathAndParams( + 'main/p/4/list/10259959195', + { code: '', foo: 'bar' } + ); + expect(action2).toEqual({ + type: NavigationActions.NAVIGATE, + routeName: 'main', + params: { + code: '', + foo: 'bar', + }, + action: { + type: NavigationActions.NAVIGATE, + routeName: 'profile', + params: { + id: '4', + code: '', + foo: 'bar', + }, + action: { + type: NavigationActions.NAVIGATE, + routeName: 'list', + params: { + id: '10259959195', + code: '', + foo: 'bar', + }, + }, + }, + }); + }); + + test('paths option on router overrides path from route config', () => { + const router = createTestRouter( + { + main: { + screen: MainNavigator, + }, + baz: { + path: null, + screen: FooNavigator, + }, + }, + { paths: { baz: 'overridden' } } + ); + const action = router.getActionForPathAndParams('overridden', {}); + expect(action.type).toEqual(NavigationActions.NAVIGATE); + expect(action.routeName).toEqual('baz'); + }); +}; + +describe('Path handling for stack router', () => { + performRouterTest(StackRouter); +}); +describe('Path handling for switch router', () => { + performRouterTest(SwitchRouter); +}); diff --git a/src/routers/__tests__/StackRouter-test.js b/src/routers/__tests__/StackRouter-test.js index 2cbb6d7..e5b9d31 100644 --- a/src/routers/__tests__/StackRouter-test.js +++ b/src/routers/__tests__/StackRouter-test.js @@ -208,6 +208,7 @@ describe('StackRouter', () => { expect(AuthNavigator.router.getActionForPathAndParams('login')).toEqual({ type: NavigationActions.NAVIGATE, routeName: 'login', + params: {}, }); }); @@ -223,7 +224,10 @@ describe('StackRouter', () => { test('Parses paths with a query', () => { expect( - TestStackRouter.getActionForPathAndParams('people/foo?code=test&foo=bar') + TestStackRouter.getActionForPathAndParams('people/foo', { + code: 'test', + foo: 'bar', + }) ).toEqual({ type: NavigationActions.NAVIGATE, routeName: 'person', @@ -237,7 +241,10 @@ describe('StackRouter', () => { test('Parses paths with an empty query value', () => { expect( - TestStackRouter.getActionForPathAndParams('people/foo?code=&foo=bar') + TestStackRouter.getActionForPathAndParams('people/foo', { + code: '', + foo: 'bar', + }) ).toEqual({ type: NavigationActions.NAVIGATE, routeName: 'person', @@ -255,9 +262,11 @@ describe('StackRouter', () => { expect(action).toEqual({ type: NavigationActions.NAVIGATE, routeName: 'auth', + params: {}, action: { type: NavigationActions.NAVIGATE, routeName: 'login', + params: {}, }, }); }); @@ -268,6 +277,7 @@ describe('StackRouter', () => { expect(action).toEqual({ type: NavigationActions.NAVIGATE, routeName: 'main', + params: {}, action: { type: NavigationActions.NAVIGATE, routeName: 'profile', @@ -291,6 +301,7 @@ describe('StackRouter', () => { expect(action).toEqual({ type: NavigationActions.NAVIGATE, routeName: 'baz', + params: {}, action: { type: NavigationActions.NAVIGATE, routeName: 'bar', @@ -313,9 +324,11 @@ describe('StackRouter', () => { expect(action).toEqual({ type: NavigationActions.NAVIGATE, routeName: 'auth', + params: {}, action: { type: NavigationActions.NAVIGATE, routeName: 'login', + params: {}, }, }); }); @@ -1047,6 +1060,48 @@ describe('StackRouter', () => { }); }); + test('Gets deep path (stack behavior)', () => { + const ScreenA = () =>
; + const ScreenB = () =>
; + ScreenA.router = StackRouter({ + Boo: { path: 'boo', screen: ScreenB }, + Baz: { path: 'baz/:bazId', screen: ScreenB }, + }); + const router = StackRouter({ + Foo: { + path: 'f/:id', + screen: ScreenA, + }, + Bar: { + screen: ScreenB, + }, + }); + + const state = { + index: 0, + isTransitioning: false, + routes: [ + { + index: 1, + key: 'Foo', + routeName: 'Foo', + params: { + id: '123', + }, + routes: [ + { key: 'Boo', routeName: 'Boo' }, + { key: 'Baz', routeName: 'Baz', params: { bazId: '321' } }, + ], + }, + { key: 'Bar', routeName: 'Bar' }, + ], + }; + const { path, params } = router.getPathAndParamsForState(state); + expect(path).toEqual('f/123/baz/321'); + expect(params.id).toEqual('123'); + expect(params.bazId).toEqual('321'); + }); + test('Handle goBack identified by key', () => { const FooScreen = () =>
; const BarScreen = () =>
; @@ -1634,400 +1689,164 @@ describe('StackRouter', () => { }); }); - test('Handles empty URIs', () => { - const router = StackRouter( - { - Foo: { - screen: () =>
, - }, - Bar: { - screen: () =>
, - }, - }, - { initialRouteName: 'Bar' } - ); - const action = router.getActionForPathAndParams(''); - expect(action).toEqual({ - type: NavigationActions.NAVIGATE, - routeName: 'Bar', + test('Handles deep navigate completion action', () => { + const LeafScreen = () =>
; + const FooScreen = () =>
; + FooScreen.router = StackRouter({ + Boo: { path: 'boo', screen: LeafScreen }, + Baz: { path: 'baz/:bazId', screen: LeafScreen }, }); - let state = null; - if (action) { - state = router.getStateForAction(action); - } + const router = StackRouter({ + Foo: { + screen: FooScreen, + }, + Bar: { + screen: LeafScreen, + }, + }); + + const state = router.getStateForAction({ type: NavigationActions.INIT }); expect(state && state.index).toEqual(0); - expect(state && state.routes[0]).toEqual( - expect.objectContaining({ - routeName: 'Bar', - }) + expect(state && state.routes[0].routeName).toEqual('Foo'); + const key = state && state.routes[0].key; + const state2 = router.getStateForAction( + { + type: NavigationActions.NAVIGATE, + routeName: 'Baz', + }, + state ); + expect(state2 && state2.index).toEqual(0); + expect(state2 && state2.isTransitioning).toEqual(false); + expect(state2 && state2.routes[0].index).toEqual(1); + expect(state2 && state2.routes[0].isTransitioning).toEqual(true); + expect(!!key).toEqual(true); + const state3 = router.getStateForAction( + { + type: StackActions.COMPLETE_TRANSITION, + }, + state2 + ); + expect(state3 && state3.index).toEqual(0); + expect(state3 && state3.isTransitioning).toEqual(false); + expect(state3 && state3.routes[0].index).toEqual(1); + expect(state3 && state3.routes[0].isTransitioning).toEqual(false); }); - test('Gets deep path', () => { - const ScreenA = () =>
; - const ScreenB = () =>
; - ScreenA.router = StackRouter({ - Boo: { path: 'boo', screen: ScreenB }, - Baz: { path: 'baz/:bazId', screen: ScreenB }, - }); - const router = StackRouter({ - Foo: { - path: 'f/:id', - screen: ScreenA, - }, - Bar: { - screen: ScreenB, - }, - }); - - const state = { - index: 0, - isTransitioning: false, - routes: [ - { - index: 1, - key: 'Foo', - routeName: 'Foo', - params: { - id: '123', - }, - routes: [ - { key: 'Boo', routeName: 'Boo' }, - { key: 'Baz', routeName: 'Baz', params: { bazId: '321' } }, - ], - }, - { key: 'Bar', routeName: 'Bar' }, - ], - }; - const { path, params } = router.getPathAndParamsForState(state); - expect(path).toEqual('f/123/baz/321'); - expect(params.id).toEqual('123'); - expect(params.bazId).toEqual('321'); - }); - - test('Gets deep path with pure wildcard match', () => { - const ScreenA = () =>
; - const ScreenB = () =>
; - const ScreenC = () =>
; - ScreenA.router = StackRouter({ - Boo: { path: 'boo', screen: ScreenC }, - Baz: { path: 'baz/:bazId', screen: ScreenB }, - }); - ScreenC.router = StackRouter({ - Boo2: { path: '', screen: ScreenB }, - }); - const router = StackRouter({ - Foo: { - path: null, - screen: ScreenA, - }, - Bar: { - screen: ScreenB, - }, - }); - - { - const state = { - index: 0, - routes: [ - { - index: 1, - key: 'Foo', - routeName: 'Foo', - params: { - id: '123', - }, - routes: [ - { - index: 0, - key: 'Boo', - routeName: 'Boo', - routes: [{ key: 'Boo2', routeName: 'Boo2' }], - }, - { key: 'Baz', routeName: 'Baz', params: { bazId: '321' } }, - ], - }, - { key: 'Bar', routeName: 'Bar' }, - ], - }; - const { path, params } = router.getPathAndParamsForState(state); - expect(path).toEqual('baz/321'); - expect(params.id).toEqual('123'); - expect(params.bazId).toEqual('321'); - } - - { - const state = { - index: 0, - routes: [ - { - index: 0, - key: 'Foo', - routeName: 'Foo', - params: { - id: '123', - }, - routes: [ - { - index: 0, - key: 'Boo', - routeName: 'Boo', - routes: [{ key: 'Boo2', routeName: 'Boo2' }], - }, - { key: 'Baz', routeName: 'Baz', params: { bazId: '321' } }, - ], - }, - { key: 'Bar', routeName: 'Bar' }, - ], - }; - const { path, params } = router.getPathAndParamsForState(state); - expect(path).toEqual('boo/'); - expect(params).toEqual({ id: '123' }); - } - }); - - test('URI encoded string get passed to deep link', () => { - const uri = 'people/2018%2F02%2F07'; - const action = TestStackRouter.getActionForPathAndParams(uri); - expect(action).toEqual({ - routeName: 'person', - params: { - id: '2018/02/07', - }, - type: NavigationActions.NAVIGATE, - }); - - const malformedUri = 'people/%E0%A4%A'; - const action2 = TestStackRouter.getActionForPathAndParams(malformedUri); - expect(action2).toEqual({ - routeName: 'person', - params: { - id: '%E0%A4%A', - }, - type: NavigationActions.NAVIGATE, - }); - }); - - test('Querystring params get passed to nested deep link', () => { - // uri with two non-empty query param values - const uri = 'main/p/4/list/10259959195?code=test&foo=bar'; - const action = TestStackRouter.getActionForPathAndParams(uri); - expect(action).toEqual({ - type: NavigationActions.NAVIGATE, - routeName: 'main', - params: { - code: 'test', - foo: 'bar', - }, - action: { - type: NavigationActions.NAVIGATE, - routeName: 'profile', - params: { - id: '4', - code: 'test', - foo: 'bar', - }, - action: { - type: NavigationActions.NAVIGATE, - routeName: 'list', - params: { - id: '10259959195', - code: 'test', - foo: 'bar', - }, - }, - }, - }); - - // uri with one empty and one non-empty query param value - const uri2 = 'main/p/4/list/10259959195?code=&foo=bar'; - const action2 = TestStackRouter.getActionForPathAndParams(uri2); - expect(action2).toEqual({ - type: NavigationActions.NAVIGATE, - routeName: 'main', - params: { - code: '', - foo: 'bar', - }, - action: { - type: NavigationActions.NAVIGATE, - routeName: 'profile', - params: { - id: '4', - code: '', - foo: 'bar', - }, - action: { - type: NavigationActions.NAVIGATE, - routeName: 'list', - params: { - id: '10259959195', - code: '', - foo: 'bar', - }, - }, - }, - }); - }); -}); - -test('Handles deep navigate completion action', () => { - const LeafScreen = () =>
; - const FooScreen = () =>
; - FooScreen.router = StackRouter({ - Boo: { path: 'boo', screen: LeafScreen }, - Baz: { path: 'baz/:bazId', screen: LeafScreen }, - }); - const router = StackRouter({ - Foo: { - screen: FooScreen, - }, - Bar: { - screen: LeafScreen, - }, - }); - - const state = router.getStateForAction({ type: NavigationActions.INIT }); - expect(state && state.index).toEqual(0); - expect(state && state.routes[0].routeName).toEqual('Foo'); - const key = state && state.routes[0].key; - const state2 = router.getStateForAction( - { - type: NavigationActions.NAVIGATE, - routeName: 'Baz', - }, - state - ); - expect(state2 && state2.index).toEqual(0); - expect(state2 && state2.isTransitioning).toEqual(false); - expect(state2 && state2.routes[0].index).toEqual(1); - expect(state2 && state2.routes[0].isTransitioning).toEqual(true); - expect(!!key).toEqual(true); - const state3 = router.getStateForAction( - { - type: StackActions.COMPLETE_TRANSITION, - }, - state2 - ); - expect(state3 && state3.index).toEqual(0); - expect(state3 && state3.isTransitioning).toEqual(false); - expect(state3 && state3.routes[0].index).toEqual(1); - expect(state3 && state3.routes[0].isTransitioning).toEqual(false); -}); - -test('order of handling navigate action is correct for nested stackrouters', () => { - const Screen = () =>
; - const NestedStack = () =>
; - let nestedRouter = StackRouter({ - Foo: Screen, - Bar: Screen, - }); - - NestedStack.router = nestedRouter; - - let router = StackRouter( - { - NestedStack, + test('order of handling navigate action is correct for nested stackrouters', () => { + const Screen = () =>
; + const NestedStack = () =>
; + let nestedRouter = StackRouter({ + Foo: Screen, Bar: Screen, - Baz: Screen, - }, - { - initialRouteName: 'Baz', - } - ); + }); - const state = router.getStateForAction({ type: NavigationActions.INIT }); - expect(state.routes[state.index].routeName).toEqual('Baz'); + NestedStack.router = nestedRouter; - const state2 = router.getStateForAction( - { - type: NavigationActions.NAVIGATE, - routeName: 'Bar', - }, - state - ); - expect(state2.routes[state2.index].routeName).toEqual('Bar'); + let router = StackRouter( + { + NestedStack, + Bar: Screen, + Baz: Screen, + }, + { + initialRouteName: 'Baz', + } + ); - const state3 = router.getStateForAction( - { - type: NavigationActions.NAVIGATE, - routeName: 'Baz', - }, - state2 - ); - expect(state3.routes[state3.index].routeName).toEqual('Baz'); + const state = router.getStateForAction({ type: NavigationActions.INIT }); + expect(state.routes[state.index].routeName).toEqual('Baz'); - const state4 = router.getStateForAction( - { - type: NavigationActions.NAVIGATE, - routeName: 'Foo', - }, - state3 - ); - let activeState4 = state4.routes[state4.index]; - expect(activeState4.routeName).toEqual('NestedStack'); - expect(activeState4.routes[activeState4.index].routeName).toEqual('Foo'); + const state2 = router.getStateForAction( + { + type: NavigationActions.NAVIGATE, + routeName: 'Bar', + }, + state + ); + expect(state2.routes[state2.index].routeName).toEqual('Bar'); - const state5 = router.getStateForAction( - { - type: NavigationActions.NAVIGATE, - routeName: 'Bar', - }, - state4 - ); - let activeState5 = state5.routes[state5.index]; - expect(activeState5.routeName).toEqual('NestedStack'); - expect(activeState5.routes[activeState5.index].routeName).toEqual('Bar'); -}); - -test('order of handling navigate action is correct for nested stackrouters', () => { - const Screen = () =>
; - const NestedStack = () =>
; - const OtherNestedStack = () =>
; - - let nestedRouter = StackRouter({ Foo: Screen, Bar: Screen }); - let otherNestedRouter = StackRouter({ Foo: Screen }); - NestedStack.router = nestedRouter; - OtherNestedStack.router = otherNestedRouter; - - let router = StackRouter( - { - NestedStack, - OtherNestedStack, - Bar: Screen, - }, - { - initialRouteName: 'OtherNestedStack', - } - ); - - const state = router.getStateForAction({ type: NavigationActions.INIT }); - expect(state.routes[state.index].routeName).toEqual('OtherNestedStack'); - - const state2 = router.getStateForAction( - { - type: NavigationActions.NAVIGATE, - routeName: 'Bar', - }, - state - ); - expect(state2.routes[state2.index].routeName).toEqual('Bar'); - - const state3 = router.getStateForAction( - { - type: NavigationActions.NAVIGATE, - routeName: 'NestedStack', - }, - state2 - ); - const state4 = router.getStateForAction( - { - type: NavigationActions.NAVIGATE, - routeName: 'Bar', - }, - state3 - ); - let activeState4 = state4.routes[state4.index]; - expect(activeState4.routeName).toEqual('NestedStack'); - expect(activeState4.routes[activeState4.index].routeName).toEqual('Bar'); + const state3 = router.getStateForAction( + { + type: NavigationActions.NAVIGATE, + routeName: 'Baz', + }, + state2 + ); + expect(state3.routes[state3.index].routeName).toEqual('Baz'); + + const state4 = router.getStateForAction( + { + type: NavigationActions.NAVIGATE, + routeName: 'Foo', + }, + state3 + ); + let activeState4 = state4.routes[state4.index]; + expect(activeState4.routeName).toEqual('NestedStack'); + expect(activeState4.routes[activeState4.index].routeName).toEqual('Foo'); + + const state5 = router.getStateForAction( + { + type: NavigationActions.NAVIGATE, + routeName: 'Bar', + }, + state4 + ); + let activeState5 = state5.routes[state5.index]; + expect(activeState5.routeName).toEqual('NestedStack'); + expect(activeState5.routes[activeState5.index].routeName).toEqual('Bar'); + }); + + test('order of handling navigate action is correct for nested stackrouters', () => { + const Screen = () =>
; + const NestedStack = () =>
; + const OtherNestedStack = () =>
; + + let nestedRouter = StackRouter({ Foo: Screen, Bar: Screen }); + let otherNestedRouter = StackRouter({ Foo: Screen }); + NestedStack.router = nestedRouter; + OtherNestedStack.router = otherNestedRouter; + + let router = StackRouter( + { + NestedStack, + OtherNestedStack, + Bar: Screen, + }, + { + initialRouteName: 'OtherNestedStack', + } + ); + + const state = router.getStateForAction({ type: NavigationActions.INIT }); + expect(state.routes[state.index].routeName).toEqual('OtherNestedStack'); + + const state2 = router.getStateForAction( + { + type: NavigationActions.NAVIGATE, + routeName: 'Bar', + }, + state + ); + expect(state2.routes[state2.index].routeName).toEqual('Bar'); + + const state3 = router.getStateForAction( + { + type: NavigationActions.NAVIGATE, + routeName: 'NestedStack', + }, + state2 + ); + const state4 = router.getStateForAction( + { + type: NavigationActions.NAVIGATE, + routeName: 'Bar', + }, + state3 + ); + let activeState4 = state4.routes[state4.index]; + expect(activeState4.routeName).toEqual('NestedStack'); + expect(activeState4.routes[activeState4.index].routeName).toEqual('Bar'); + }); }); diff --git a/src/routers/__tests__/SwitchRouter-test.js b/src/routers/__tests__/SwitchRouter-test.js index d22edde..506d546 100644 --- a/src/routers/__tests__/SwitchRouter-test.js +++ b/src/routers/__tests__/SwitchRouter-test.js @@ -78,56 +78,6 @@ describe('SwitchRouter', () => { expect(state3.index).toEqual(0); }); - test('paths option on SwitchRouter overrides path from route config', () => { - const router = getExampleRouter({ paths: { A: 'overridden' } }); - const action = router.getActionForPathAndParams('overridden', {}); - expect(action.type).toEqual(NavigationActions.NAVIGATE); - expect(action.routeName).toEqual('A'); - }); - - test('provides correct action for getActionForPathAndParams', () => { - const router = getExampleRouter({ backBehavior: 'initialRoute' }); - const action = router.getActionForPathAndParams('A1', { foo: 'bar' }); - expect(action.type).toEqual(NavigationActions.NAVIGATE); - expect(action.routeName).toEqual('A1'); - - const action1 = router.getActionForPathAndParams('', {}); - expect(action1.type).toEqual(NavigationActions.NAVIGATE); - expect(action1.routeName).toEqual('A'); - - const action2 = router.getActionForPathAndParams(null, {}); - expect(action2.type).toEqual(NavigationActions.NAVIGATE); - expect(action2.routeName).toEqual('A'); - - const action3 = router.getActionForPathAndParams('great/path', { - foo: 'baz', - }); - expect(action3).toEqual({ - type: NavigationActions.NAVIGATE, - routeName: 'B', - params: { foo: 'baz' }, - action: { - type: NavigationActions.NAVIGATE, - routeName: 'B1', - params: { foo: 'baz' }, - }, - }); - - const action4 = router.getActionForPathAndParams('great/path/B2', { - foo: 'baz', - }); - expect(action4).toEqual({ - type: NavigationActions.NAVIGATE, - routeName: 'B', - params: { foo: 'baz' }, - action: { - type: NavigationActions.NAVIGATE, - routeName: 'B2', - params: { foo: 'baz' }, - }, - }); - }); - test('order of handling navigate action is correct for nested switchrouters', () => { // router = switch({ Nested: switch({ Foo, Bar }), Other: switch({ Foo }), Bar }) // if we are focused on Other and navigate to Bar, what should happen? diff --git a/src/routers/__tests__/TabRouter-test.js b/src/routers/__tests__/TabRouter-test.js index 6ef9d0e..48a0069 100644 --- a/src/routers/__tests__/TabRouter-test.js +++ b/src/routers/__tests__/TabRouter-test.js @@ -528,7 +528,7 @@ describe('TabRouter', () => { }); }); - test('Handles path configuration', () => { + test.only('Handles path configuration', () => { const ScreenA = () =>
; const ScreenB = () =>
; const router = TabRouter({ @@ -537,14 +537,17 @@ describe('TabRouter', () => { screen: ScreenA, }, Bar: { - path: 'b', + path: 'b/:great', screen: ScreenB, }, }); const params = { foo: '42' }; const action = router.getActionForPathAndParams('b/anything', params); const expectedAction = { - params, + params: { + foo: '42', + great: 'anything', + }, routeName: 'Bar', type: NavigationActions.NAVIGATE, }; @@ -565,15 +568,21 @@ describe('TabRouter', () => { index: 1, isTransitioning: false, routes: [ - { key: 'Foo', routeName: 'Foo' }, - { key: 'Bar', routeName: 'Bar', params }, + { key: 'Foo', routeName: 'Foo', params: undefined }, + { + key: 'Bar', + routeName: 'Bar', + params: { foo: '42', great: 'anything' }, + }, ], }; expect(state2).toEqual(expectedState2); expect(router.getComponentForState(expectedState)).toEqual(ScreenA); expect(router.getComponentForState(expectedState2)).toEqual(ScreenB); expect(router.getPathAndParamsForState(expectedState).path).toEqual('f'); - expect(router.getPathAndParamsForState(expectedState2).path).toEqual('b'); + expect(router.getPathAndParamsForState(expectedState2).path).toEqual( + 'b/anything' + ); }); test('Handles default configuration', () => { diff --git a/src/routers/__tests__/pathUtils-test.js b/src/routers/__tests__/pathUtils-test.js new file mode 100644 index 0000000..588a44e --- /dev/null +++ b/src/routers/__tests__/pathUtils-test.js @@ -0,0 +1,34 @@ +import { urlToPathAndParams } from '../pathUtils'; + +test('urlToPathAndParams empty', () => { + const { path, params } = urlToPathAndParams('foo://'); + expect(path).toBe(''); + expect(params).toEqual({}); +}); + +test('urlToPathAndParams empty params', () => { + const { path, params } = urlToPathAndParams('foo://foo/bar/b'); + expect(path).toBe('foo/bar/b'); + expect(params).toEqual({}); +}); + +test('urlToPathAndParams trailing slash', () => { + const { path, params } = urlToPathAndParams('foo://foo/bar/'); + expect(path).toBe('foo/bar'); + expect(params).toEqual({}); +}); + +test('urlToPathAndParams with params', () => { + const { path, params } = urlToPathAndParams('foo://foo/bar?asdf=1&dude=foo'); + expect(path).toBe('foo/bar'); + expect(params).toEqual({ asdf: '1', dude: 'foo' }); +}); + +test('urlToPathAndParams with custom delimeter', () => { + const { path, params } = urlToPathAndParams( + 'https://example.com/foo/bar?asdf=1', + 'https://example.com/' + ); + expect(path).toBe('foo/bar'); + expect(params).toEqual({ asdf: '1' }); +}); diff --git a/src/routers/pathUtils.js b/src/routers/pathUtils.js new file mode 100644 index 0000000..492e3eb --- /dev/null +++ b/src/routers/pathUtils.js @@ -0,0 +1,172 @@ +import pathToRegexp from 'path-to-regexp'; +import NavigationActions from '../NavigationActions'; +const queryString = require('query-string'); + +function isEmpty(obj) { + if (!obj) return true; + for (let key in obj) { + return false; + } + return true; +} + +export const urlToPathAndParams = (url, uriPrefix) => { + const searchMatch = url.match(/^(.*)\?(.*)$/); + const params = searchMatch ? queryString.parse(searchMatch[2]) : {}; + const urlWithoutSearch = searchMatch ? searchMatch[1] : url; + const delimiter = uriPrefix || '://'; + let path = urlWithoutSearch.split(delimiter)[1]; + if (path === undefined) { + path = urlWithoutSearch; + } + if (path === '/') { + path = ''; + } + if (path[path.length - 1] === '/') { + path = path.slice(0, -1); + } + return { + path, + params, + }; +}; + +export const createPathParser = ( + childRouters, + routeConfigs, + pathConfigs = {}, + initialRouteName, + initialRouteParams +) => { + const pathsByRouteNames = {}; + let paths = []; + + // Build paths for each route + Object.keys(childRouters).forEach(routeName => { + let pathPattern = pathConfigs[routeName] || routeConfigs[routeName].path; + let matchExact = !!pathPattern && !childRouters[routeName]; + if (pathPattern === undefined) { + pathPattern = routeName; + } + const keys = []; + let re, toPath, priority; + if (typeof pathPattern === 'string') { + // pathPattern may be either a string or a regexp object according to path-to-regexp docs. + re = pathToRegexp(pathPattern, keys); + toPath = pathToRegexp.compile(pathPattern); + priority = 0; + } else if (pathPattern === null) { + // for wildcard match + re = pathToRegexp('*', keys); + toPath = () => ''; + matchExact = true; + priority = -1; + } + if (!matchExact) { + const wildcardRe = pathToRegexp(`${pathPattern}/*`, keys); + re = new RegExp(`(?:${re.source})|(?:${wildcardRe.source})`); + } + pathsByRouteNames[routeName] = { re, keys, toPath, priority, pathPattern }; + }); + + paths = Object.entries(pathsByRouteNames); + paths.sort((a, b) => b[1].priority - a[1].priority); + + const 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, + params: { ...inputParams, ...initialRouteParams }, + }); + } + + // Attempt to match `pathToResolve` 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 paths) { + const { re, keys } = path; + pathMatch = re.exec(pathToResolve); + if (pathMatch && pathMatch.length) { + pathMatchKeys = keys; + matchedRouteName = routeName; + break; + } + } + + // We didn't match -- return null to signify no action available + 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; + if (childRouters[matchedRouteName]) { + nestedAction = childRouters[matchedRouteName].getActionForPathAndParams( + pathMatch.slice(pathMatchKeys.length).join('/'), + inputParams + ); + if (!nestedAction) { + return null; + } + } + + const params = pathMatch.slice(1).reduce( + // iterate over matched path params + (paramsOut, matchResult, i) => { + const key = pathMatchKeys[i]; + if (!key || key.asterisk) { + return paramsOut; + } + const paramName = key.name; + + let decodedMatchResult; + try { + decodedMatchResult = decodeURIComponent(matchResult); + } catch (e) { + // ignore `URIError: malformed URI` + } + + paramsOut[paramName] = decodedMatchResult || matchResult; + return paramsOut; + }, + { + // start with the input(query string) params, which will get overridden by path params + ...inputParams, + } + ); + + return NavigationActions.navigate({ + routeName: matchedRouteName, + ...(params ? { params } : {}), + ...(nestedAction ? { action: nestedAction } : {}), + }); + }; + const getPathAndParamsForRoute = route => { + const { routeName, params } = route; + const childRouter = childRouters[routeName]; + const subPath = pathsByRouteNames[routeName].toPath(params); + if (childRouter) { + // If it has a router it's a navigator. + // If it doesn't have router it's an ordinary React component. + const child = childRouter.getPathAndParamsForState(route); + return { + path: subPath ? `${subPath}/${child.path}` : child.path, + params: child.params ? { ...params, ...child.params } : params, + }; + } + return { + path: subPath, + params, + }; + }; + return { getActionForPathAndParams, getPathAndParamsForRoute }; +}; diff --git a/yarn.lock b/yarn.lock index d7c1d55..8095082 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4659,6 +4659,13 @@ qs@~6.5.1: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" +query-string@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.1.0.tgz#01e7d69f6a0940dac67a937d6c6325647aa4532a" + dependencies: + decode-uri-component "^0.2.0" + strict-uri-encode "^2.0.0" + random-bytes@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" @@ -5565,6 +5572,10 @@ stream-to-observable@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/stream-to-observable/-/stream-to-observable-0.1.0.tgz#45bf1d9f2d7dc09bed81f1c307c430e68b84cffe" +strict-uri-encode@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" + string-length@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed"