Routers: Deep Linking Overhaul (#4590)

* deep linking overhaul

* clean up PlatformHelpers

this had previously been required for old versions of react native and react-native-web
This commit is contained in:
Eric Vicenti 2018-06-29 07:27:12 -07:00 committed by Brent Vatne
parent 3d06d19d6a
commit 4e384f8057
11 changed files with 772 additions and 686 deletions

View File

@ -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",

View File

@ -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

View File

@ -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(

View File

@ -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(

View File

@ -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 = () => <div />;
const ProfileNavigator = () => <div />;
ProfileNavigator.router = StackRouter({
list: {
path: 'list/:id',
screen: ListScreen,
},
});
const MainNavigator = () => <div />;
MainNavigator.router = StackRouter({
profile: {
path: 'p/:id',
screen: ProfileNavigator,
},
});
const LoginScreen = () => <div />;
const AuthNavigator = () => <div />;
AuthNavigator.router = StackRouter({
login: {
screen: LoginScreen,
},
});
const BarScreen = () => <div />;
class FooNavigator extends React.Component {
static router = StackRouter({
bar: {
path: 'b/:barThing',
screen: BarScreen,
},
});
render() {
return <div />;
}
}
const PersonScreen = () => <div />;
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: () => <div />,
},
Bar: {
screen: () => <div />,
},
},
{ 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 = () => <div />;
const ScreenB = () => <div />;
const ScreenC = () => <div />;
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);
});

View File

@ -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 = () => <div />;
const ScreenB = () => <div />;
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 = () => <div />;
const BarScreen = () => <div />;
@ -1634,244 +1689,7 @@ describe('StackRouter', () => {
});
});
test('Handles empty URIs', () => {
const router = StackRouter(
{
Foo: {
screen: () => <div />,
},
Bar: {
screen: () => <div />,
},
},
{ initialRouteName: 'Bar' }
);
const action = router.getActionForPathAndParams('');
expect(action).toEqual({
type: NavigationActions.NAVIGATE,
routeName: 'Bar',
});
let state = null;
if (action) {
state = router.getStateForAction(action);
}
expect(state && state.index).toEqual(0);
expect(state && state.routes[0]).toEqual(
expect.objectContaining({
routeName: 'Bar',
})
);
});
test('Gets deep path', () => {
const ScreenA = () => <div />;
const ScreenB = () => <div />;
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 = () => <div />;
const ScreenB = () => <div />;
const ScreenC = () => <div />;
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', () => {
test('Handles deep navigate completion action', () => {
const LeafScreen = () => <div />;
const FooScreen = () => <div />;
FooScreen.router = StackRouter({
@ -1913,9 +1731,9 @@ test('Handles deep navigate completion action', () => {
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', () => {
test('order of handling navigate action is correct for nested stackrouters', () => {
const Screen = () => <div />;
const NestedStack = () => <div />;
let nestedRouter = StackRouter({
@ -1978,9 +1796,9 @@ test('order of handling navigate action is correct for nested stackrouters', ()
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', () => {
test('order of handling navigate action is correct for nested stackrouters', () => {
const Screen = () => <div />;
const NestedStack = () => <div />;
const OtherNestedStack = () => <div />;
@ -2030,4 +1848,5 @@ test('order of handling navigate action is correct for nested stackrouters', ()
let activeState4 = state4.routes[state4.index];
expect(activeState4.routeName).toEqual('NestedStack');
expect(activeState4.routes[activeState4.index].routeName).toEqual('Bar');
});
});

View File

@ -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?

View File

@ -528,7 +528,7 @@ describe('TabRouter', () => {
});
});
test('Handles path configuration', () => {
test.only('Handles path configuration', () => {
const ScreenA = () => <div />;
const ScreenB = () => <div />;
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', () => {

View File

@ -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' });
});

172
src/routers/pathUtils.js Normal file
View File

@ -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 };
};

View File

@ -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"