Compare commits

..

20 Commits

Author SHA1 Message Date
Eric Vicenti
655453aed3 reactotron hello world 2018-07-12 11:28:46 -07:00
Eric Vicenti
c4b84f1d66 Fix container referene to startup state data 2018-07-12 11:01:11 -07:00
Michael Lefkowitz
69f394be5b Feat/allow keyless replace (#4636)
* allow key to be undefined on StackNavigation.replace method

* added tests for replace action w/out key

* fix typo

* updated changelog

* updated teests for clarity

* added length check on routes to safely fallthrough to search
2018-07-10 23:41:42 -07:00
Eric Vicenti
316e4991ac Add enableURLHandling to navigation container 2018-07-10 14:47:57 -07:00
Ashoat Tevosyan
805064cb5e [flow] Make NavigationRoute types exact (#4667)
React Native 0.56 introduces Flow 0.75, which makes it impossible to refine `NavigationRoute` based on the presence of `index` or `routes` properties.

This PR turns `NavigationStateRoute` and `NavigationLeafRoute` into exact types, which addresses this issue.
2018-07-10 13:59:09 -07:00
Dariusz Łuksza
8f199980cb Fix changelog (#4651)
Fixes 2.6.1 header link
2018-07-06 20:21:00 -07:00
Brent Vatne
37ca6a92ca Release 2.6.2 2018-07-06 10:44:30 -07:00
Brent Vatne
980e0409dc Temporarily remove warnings on vertical padding in header 2018-07-06 10:42:32 -07:00
Brent Vatne
a00ba5918a Default to 0 elevation on transparent header 2018-07-05 15:17:08 -07:00
Brent Vatne
ad6b25cff9 Fix 2.6.1 changelog 2018-07-05 15:07:17 -07:00
Brent Vatne
a69b67d6d2 Release 2.6.1 2018-07-05 15:03:14 -07:00
Brent Vatne
dc436e4d01 Warn for more invalid header styles 2018-07-05 15:02:48 -07:00
Brent Vatne
fe95bdeee6 Fix regression for shadow in header on Android 2018-07-05 14:53:41 -07:00
Brent Vatne
525528e38f Release 2.6.0 2018-07-04 13:04:00 -07:00
liuqiang1357
9f5f3d994c Fix bug: params not be passed to navigator inside SwitchNavigator (#4306) 2018-07-04 12:28:01 -07:00
Eric Vicenti
e8c1833053 Fix stack router child router delegation priority (#4635)
Stack router had some aggressive logic for deferring to inactive child routers. The child router behavior should come after all of the appropriate stack actions, with the exception of the active child router.

This was causing issues such as https://github.com/react-navigation/react-navigation/issues/4623 , where inactive tab navigators would handle the back action, and cause the stack to attempt to  pop back to it.
2018-07-03 21:18:25 -07:00
Eric Vicenti
0921889f7a Avoid crash when calling isFocused on old route (#4634) 2018-07-03 12:03:54 -07:00
Sébastien Lorber
1951a3ac46 Add <NavigationEvents/> component (#4188)
* add NavigationEvents

* expose TabsWithNavigationEvents in lib entrypoints

* Add NavigationEvents example in playground

* Add NavigationEvents example in playground

* Add NavigationEvents tests

* Add NavigationEvents Flow declarations

* remove useless NavigationEvents constructor

* NavigationEvents => make tests more readable by avoiding beforeEach callback

* fix flow test error by adding <any, any> to React.Component
2018-06-29 07:34:11 -07:00
Eric Vicenti
4e384f8057 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
2018-06-29 07:27:12 -07:00
Eric Vicenti
3d06d19d6a clean up PlatformHelpers (#4586)
this had previously been required for old versions of react native and react-native-web
2018-06-28 10:02:52 -07:00
25 changed files with 1547 additions and 752 deletions

View File

@@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- StackNavigator.replace method no longer requires a key param. If the key is left undefined, the last screen in the stack will be replaced.
## [2.6.2] - [2018-07-06](https://github.com/react-navigation/react-navigation/releases/tag/2.6.2)
### Changed
- Relax vertical padding warnings on header.
## [2.6.1] - [2018-07-05](https://github.com/react-navigation/react-navigation/releases/tag/2.6.1)
### Added
- Warn for more invalid headerStyle properties (padding, top/right/bottom/left, position).
### Fixed
- Fixed missing header shadow on Android.
## [2.6.0] - [2018-07-04](https://github.com/react-navigation/react-navigation/releases/tag/2.6.0)
### Added
- [NavigationEvents](https://github.com/react-navigation/react-navigation/pull/4188) component as a declarative interface for subscribing to navigation focus events.
### Fixed
- Fix stack router child router delegation priority (https://github.com/react-navigation/react-navigation/commit/e8c1833053e37d28f0ce505ff323565abf23b6a2)
- Avoid crash when calling isFocused on old route (https://github.com/react-navigation/react-navigation/commit/0921889f7a3acfc6d6bcc4909d209eeeee985ba7)
- Stack router no longer attempts to parse query params within path handling
- Switch router now has exact same param treatment for URLs as stack router does
### Changed
- Internally we no longer need to special case PlatformHelpers by platform as react-native-web handles the APIs we mocked out with it now.
## [2.5.5] - [2018-06-27](https://github.com/react-navigation/react-navigation/releases/tag/2.5.5)
### Added
@@ -45,7 +71,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
### Changed
- Improved examples
[Unreleased]: https://github.com/react-navigation/react-navigation/compare/2.5.5...HEAD
[Unreleased]: https://github.com/react-navigation/react-navigation/compare/2.6.2...HEAD
[2.6.2]: https://github.com/react-navigation/react-navigation/compare/2.6.1...2.6.2
[2.6.1]: https://github.com/react-navigation/react-navigation/compare/2.6.0...2.6.1
[2.6.0]: https://github.com/react-navigation/react-navigation/compare/2.5.5...2.6.0
[2.5.5]: https://github.com/react-navigation/react-navigation/compare/2.5.4...2.5.5
[2.5.4]: https://github.com/react-navigation/react-navigation/compare/2.5.3...2.5.4
[2.5.3]: https://github.com/react-navigation/react-navigation/compare/2.5.2...2.5.3

View File

@@ -16,6 +16,9 @@ import {
StatusBar,
View,
} from 'react-native';
import Reactotron from 'reactotron-react-native';
import { SafeAreaView, createStackNavigator } from 'react-navigation';
import CustomTabs from './CustomTabs';
@@ -36,8 +39,14 @@ import StackWithTranslucentHeader from './StackWithTranslucentHeader';
import SimpleTabs from './SimpleTabs';
import SwitchWithStacks from './SwitchWithStacks';
import TabsWithNavigationFocus from './TabsWithNavigationFocus';
import TabsWithNavigationEvents from './TabsWithNavigationEvents';
import KeyboardHandlingExample from './KeyboardHandlingExample';
Reactotron.configure()
.useReactNative()
.connect();
console.tron = Reactotron;
const ExampleInfo = {
SimpleStack: {
name: 'Stack Example',
@@ -126,6 +135,11 @@ const ExampleInfo = {
name: 'withNavigationFocus',
description: 'Receive the focus prop to know when a screen is focused',
},
TabsWithNavigationEvents: {
name: 'NavigationEvents',
description:
'Declarative NavigationEvents component to subscribe to navigation events',
},
KeyboardHandlingExample: {
name: 'Keyboard Handling Example',
description:
@@ -166,6 +180,7 @@ const ExampleRoutes = {
path: 'settings',
},
TabsWithNavigationFocus,
TabsWithNavigationEvents,
KeyboardHandlingExample,
// This is commented out because it's rarely useful
// InactiveStack,
@@ -333,7 +348,9 @@ const AppNavigator = createStackNavigator(
}
);
export default AppNavigator;
const App = () => <AppNavigator persistenceKey="yes" />;
export default App;
const styles = StyleSheet.create({
item: {

View File

@@ -0,0 +1,127 @@
/**
* @flow
*/
import React from 'react';
import { FlatList, SafeAreaView, StatusBar, Text, View } from 'react-native';
import { NavigationEvents } from 'react-navigation';
import { createMaterialBottomTabNavigator } from 'react-navigation-material-bottom-tabs';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
const Event = ({ event }) => (
<View
style={{
borderColor: 'grey',
borderWidth: 1,
borderRadius: 3,
padding: 5,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Text>{event.type}</Text>
<Text>
{event.action.type.replace('Navigation/', '')}
{event.action.routeName ? '=>' + event.action.routeName : ''}
</Text>
</View>
);
const createTabScreen = (name, icon, focusedIcon) => {
class TabScreen extends React.Component<any, any> {
static navigationOptions = {
tabBarLabel: name,
tabBarIcon: ({ tintColor, focused }) => (
<MaterialCommunityIcons
name={focused ? focusedIcon : icon}
size={26}
style={{ color: focused ? tintColor : '#ccc' }}
/>
),
};
state = { eventLog: [] };
append = navigationEvent => {
this.setState(({ eventLog }) => ({
eventLog: eventLog.concat(navigationEvent),
}));
};
render() {
return (
<SafeAreaView
forceInset={{ horizontal: 'always', top: 'always' }}
style={{
flex: 1,
}}
>
<Text
style={{
margin: 10,
marginTop: 30,
fontSize: 30,
fontWeight: 'bold',
}}
>
Events for tab {name}
</Text>
<View style={{ flex: 1, width: '100%', marginTop: 10 }}>
<FlatList
data={this.state.eventLog}
keyExtractor={item => `${this.state.eventLog.indexOf(item)}`}
renderItem={({ item }) => (
<View
style={{
marginVertical: 5,
marginHorizontal: 10,
backgroundColor: '#e4e4e4',
}}
>
<Event event={item} />
</View>
)}
/>
</View>
<NavigationEvents
onWillFocus={this.append}
onDidFocus={this.append}
onWillBlur={this.append}
onDidBlur={this.append}
/>
<StatusBar barStyle="default" />
</SafeAreaView>
);
}
}
return TabScreen;
};
const TabsWithNavigationEvents = createMaterialBottomTabNavigator(
{
One: {
screen: createTabScreen('One', 'numeric-1-box-outline', 'numeric-1-box'),
},
Two: {
screen: createTabScreen('Two', 'numeric-2-box-outline', 'numeric-2-box'),
},
Three: {
screen: createTabScreen(
'Three',
'numeric-3-box-outline',
'numeric-3-box'
),
},
},
{
shifting: false,
activeTintColor: '#F44336',
}
);
export default TabsWithNavigationEvents;

View File

@@ -19,7 +19,8 @@
"react-navigation": "link:../..",
"react-navigation-header-buttons": "^0.0.4",
"react-navigation-material-bottom-tabs": "0.1.3",
"react-navigation-tabs": "^0.5.1"
"react-navigation-tabs": "^0.5.1",
"reactotron-react-native": "^2.0.0"
},
"devDependencies": {
"babel-jest": "^22.4.1",

View File

@@ -4734,6 +4734,10 @@ minizlib@^1.1.0:
dependencies:
minipass "^2.2.1"
mitt@^1.1.2:
version "1.1.3"
resolved "https://registry.yarnpkg.com/mitt/-/mitt-1.1.3.tgz#528c506238a05dce11cd914a741ea2cc332da9b8"
mixin-deep@^1.2.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe"
@@ -5446,6 +5450,13 @@ qs@6.5.2, qs@^6.4.0, qs@^6.5.0, qs@^6.5.1, 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"
querystring@0.2.0, querystring@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620"
@@ -5758,9 +5769,9 @@ react-navigation-deprecated-tab-navigator@1.3.0:
dependencies:
react-native-tab-view "^0.0.77"
react-navigation-drawer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/react-navigation-drawer/-/react-navigation-drawer-0.3.0.tgz#641007213f0f1e1b55a0a4bb64d71df07b3e7208"
react-navigation-drawer@0.4.3:
version "0.4.3"
resolved "https://registry.yarnpkg.com/react-navigation-drawer/-/react-navigation-drawer-0.4.3.tgz#c04c94e2429b7e724801af05bd0a93a79cb27f71"
dependencies:
react-native-drawer-layout-polyfill "^1.3.2"
@@ -5871,6 +5882,18 @@ react@^16.0.0:
object-assign "^4.1.1"
prop-types "^15.6.0"
reactotron-core-client@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/reactotron-core-client/-/reactotron-core-client-2.0.0.tgz#0229e7938ed17104b846c50295ae8cb40557e83c"
reactotron-react-native@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/reactotron-react-native/-/reactotron-react-native-2.0.0.tgz#54d1119c6640b7e8c6c7383e482474d4cef11016"
dependencies:
mitt "^1.1.2"
prop-types "^15.5.10"
reactotron-core-client "^2.0.0"
read-chunk@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/read-chunk/-/read-chunk-2.1.0.tgz#6a04c0928005ed9d42e1a6ac5600e19cbc7ff655"
@@ -6570,6 +6593,10 @@ stream-parser@~0.3.1:
dependencies:
debug "2"
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"

View File

@@ -184,7 +184,7 @@ declare module 'react-navigation' {
| NavigationLeafRoute
| NavigationStateRoute;
declare export type NavigationLeafRoute = {
declare export type NavigationLeafRoute = {|
/**
* React's key used by some navigators. No need to specify these manually,
* they will be defined by the router.
@@ -204,10 +204,12 @@ declare module 'react-navigation' {
* e.g. `{ car_id: 123 }` in a route that displays a car.
*/
params?: NavigationParams,
};
|};
declare export type NavigationStateRoute = NavigationLeafRoute &
NavigationState;
declare export type NavigationStateRoute = {|
...NavigationLeafRoute,
...$Exact<NavigationState>,
|};
/**
* Router
@@ -557,6 +559,21 @@ declare module 'react-navigation' {
navigationOptions?: O,
}>;
/**
* NavigationEvents component
*/
declare type _NavigationEventsProps = {
navigation?: NavigationScreenProp<NavigationState>,
onWillFocus?: NavigationEventCallback,
onDidFocus?: NavigationEventCallback,
onWillBlur?: NavigationEventCallback,
onDidBlur?: NavigationEventCallback,
};
declare export var NavigationEvents: React$ComponentType<
_NavigationEventsProps
>;
/**
* Navigation container
*/

View File

@@ -1,6 +1,6 @@
{
"name": "react-navigation",
"version": "2.5.5",
"version": "2.6.2",
"description": "Routing and navigation for your React Native apps",
"main": "src/react-navigation.js",
"repository": {
@@ -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

@@ -1,9 +0,0 @@
import {
BackAndroid as DeprecatedBackAndroid,
BackHandler as ModernBackHandler,
MaskedViewIOS,
} from 'react-native';
const BackHandler = ModernBackHandler || DeprecatedBackAndroid;
export { BackHandler, MaskedViewIOS };

View File

@@ -1,6 +0,0 @@
import React from 'react';
import { BackHandler, View } from 'react-native';
const MaskedViewIOS = () => <View>{this.props.children}</View>;
export { BackHandler, MaskedViewIOS };

View File

@@ -1,12 +1,12 @@
import React from 'react';
import { AsyncStorage, Linking, Platform } from 'react-native';
import { AsyncStorage, Linking, Platform, BackHandler } from 'react-native';
import { polyfill } from 'react-lifecycles-compat';
import { BackHandler } from './PlatformHelpers';
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;
@@ -129,23 +129,12 @@ 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 { enableURLHandling, uriPrefix } = this.props;
if (enableURLHandling === false) {
return;
}
const parsedUrl = urlToPathAndParams(url, uriPrefix);
if (parsedUrl) {
const { path, params } = parsedUrl;
const action = Component.router.getActionForPathAndParams(path, params);
@@ -214,11 +203,15 @@ export default function createNavigationContainer(Component) {
Linking.addEventListener('url', this._handleOpenURL);
// Pull out anything that can impact state
const { persistenceKey } = this.props;
const startupStateJSON =
persistenceKey && (await AsyncStorage.getItem(persistenceKey));
const url = await Linking.getInitialURL();
const parsedUrl = url && this._urlToPathAndParams(url);
const { persistenceKey, uriPrefix, enableURLHandling } = this.props;
let parsedUrl = null;
let startupStateJSON = null;
if (enableURLHandling !== false) {
startupStateJSON =
persistenceKey && (await AsyncStorage.getItem(persistenceKey));
const url = await Linking.getInitialURL();
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
@@ -272,6 +265,16 @@ export default function createNavigationContainer(Component) {
return;
}
console.tron &&
console.tron.display({
name: 'Navigation',
preview: 'Initial State',
value: {
initialState: startupState,
initialAction: this._initialAction,
},
});
this.setState({ nav: startupState }, () => {
_reactNavigationIsHydratingState = false;
dispatchActions();

View File

@@ -19,6 +19,10 @@ function getChildNavigation(navigation, childKey, getCurrentParentNavigation) {
const childRoute = navigation.state.routes.find(r => r.key === childKey);
if (!childRoute) {
return null;
}
if (children[childKey] && children[childKey].state === childRoute) {
return children[childKey];
}
@@ -79,12 +83,16 @@ function getChildNavigation(navigation, childKey, getCurrentParentNavigation) {
getParam: createParamGetter(childRoute),
getChildNavigation: grandChildKey =>
getChildNavigation(children[childKey], grandChildKey, () =>
getCurrentParentNavigation().getChildNavigation(childKey)
),
getChildNavigation(children[childKey], grandChildKey, () => {
const nav = getCurrentParentNavigation();
return nav && nav.getChildNavigation(childKey);
}),
isFocused: () => {
const currentNavigation = getCurrentParentNavigation();
if (!currentNavigation) {
return false;
}
const { routes, index } = currentNavigation.state;
if (!currentNavigation.isFocused()) {
return false;

View File

@@ -156,6 +156,11 @@ module.exports = {
return require('./views/SwitchView/SwitchView').default;
},
// NavigationEvents
get NavigationEvents() {
return require('./views/NavigationEvents').default;
},
// HOCs
get withNavigation() {
return require('./views/withNavigation').default;

View File

@@ -42,6 +42,11 @@ module.exports = {
return require('./routers/SwitchRouter').default;
},
// NavigationEvents
get NavigationEvents() {
return require('./views/NavigationEvents').default;
},
// HOCs
get withNavigation() {
return require('./views/withNavigation').default;

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,
@@ -225,37 +193,27 @@ export default (routeConfigs, stackConfig = {}) => {
return getInitialState(action);
}
const activeChildRoute = state.routes[state.index];
if (
!isResetToRootStack(action) &&
action.type !== NavigationActions.NAVIGATE
) {
const keyIndex = action.key
? StateUtils.indexOf(state, action.key)
: -1;
// Traverse routes from the top of the stack to the bottom, so the
// active route has the first opportunity, then the one before it, etc.
for (let childRoute of state.routes.slice().reverse()) {
// If a key is provided and in routes state then let's use that
// knowledge to skip extra getStateForAction calls on other child
// routers
if (keyIndex >= 0 && childRoute.key !== action.key) {
continue;
}
let childRouter = childRouters[childRoute.routeName];
if (childRouter) {
const route = childRouter.getStateForAction(action, childRoute);
if (route === null) {
return state;
} else if (route && route !== childRoute) {
return StateUtils.replaceAt(
state,
childRoute.key,
route,
action.type === NavigationActions.SET_PARAMS
);
}
// Let the active child router handle the action
const activeChildRouter = childRouters[activeChildRoute.routeName];
if (activeChildRouter) {
const route = activeChildRouter.getStateForAction(
action,
activeChildRoute
);
if (route !== null && route !== activeChildRoute) {
return StateUtils.replaceAt(
state,
activeChildRoute.key,
route,
// the following tells replaceAt to NOT change the index to this route for the setParam action, because people don't expect param-setting actions to switch the active route
action.type === NavigationActions.SET_PARAMS
);
}
}
} else if (action.type === NavigationActions.NAVIGATE) {
@@ -439,7 +397,15 @@ export default (routeConfigs, stackConfig = {}) => {
// Handle replace action
if (action.type === StackActions.REPLACE) {
const routeIndex = state.routes.findIndex(r => r.key === action.key);
let routeIndex;
// If the key param is undefined, set the index to the last route in the stack
if (action.key === undefined && state.routes.length) {
routeIndex = state.routes.length - 1;
} else {
routeIndex = state.routes.findIndex(r => r.key === action.key);
}
// Only replace if the key matches one of our routes
if (routeIndex !== -1) {
const childRouter = childRouters[action.routeName];
@@ -554,126 +520,52 @@ export default (routeConfigs, stackConfig = {}) => {
}
}
// By this point in the router's state handling logic, we have handled the behavior of the active route, and handled any stack actions.
// If we haven't returned by now, we should allow non-active child routers to handle this action, and switch to that index if the child state (route) does change..
const keyIndex = action.key ? StateUtils.indexOf(state, action.key) : -1;
// Traverse routes from the top of the stack to the bottom, so the
// active route has the first opportunity, then the one before it, etc.
for (let childRoute of state.routes.slice().reverse()) {
if (childRoute.key === activeChildRoute.key) {
// skip over the active child because we let it attempt to handle the action earlier
continue;
}
// If a key is provided and in routes state then let's use that
// knowledge to skip extra getStateForAction calls on other child
// routers
if (keyIndex >= 0 && childRoute.key !== action.key) {
continue;
}
let childRouter = childRouters[childRoute.routeName];
if (childRouter) {
const route = childRouter.getStateForAction(action, childRoute);
if (route === null) {
return state;
} else if (route && route !== childRoute) {
return StateUtils.replaceAt(
state,
childRoute.key,
route,
// the following tells replaceAt to NOT change the index to this route for the setParam action, because people don't expect param-setting actions to switch the active route
action.type === NavigationActions.SET_PARAMS
);
}
}
}
return state;
},
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}'.` +
@@ -187,7 +196,7 @@ export default (routeConfigs, config = {}) => {
newChildState = childRouter
? childRouter.getStateForAction(action.action, childState)
: null;
} else if (!action.action && !childRouter && action.params) {
} else if (!action.action && action.params) {
newChildState = {
...childState,
params: {
@@ -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: {},
},
});
});
@@ -957,6 +970,77 @@ describe('StackRouter', () => {
expect(replacedState2.routes[0].routeName).toEqual('bar');
});
test('Replace action returns most recent route if no key is provided', () => {
const GrandChildNavigator = () => <div />;
GrandChildNavigator.router = StackRouter({
Quux: { screen: () => <div /> },
Corge: { screen: () => <div /> },
Grault: { screen: () => <div /> },
});
const ChildNavigator = () => <div />;
ChildNavigator.router = StackRouter({
Baz: { screen: () => <div /> },
Woo: { screen: () => <div /> },
Qux: { screen: GrandChildNavigator },
});
const router = StackRouter({
Foo: { screen: () => <div /> },
Bar: { screen: ChildNavigator },
});
const state = router.getStateForAction({ type: NavigationActions.INIT });
const state2 = router.getStateForAction(
{
type: NavigationActions.NAVIGATE,
routeName: 'Bar',
},
state
);
const state3 = router.getStateForAction(
{
type: NavigationActions.NAVIGATE,
routeName: 'Qux',
},
state2
);
const state4 = router.getStateForAction(
{
type: NavigationActions.NAVIGATE,
routeName: 'Corge',
},
state3
);
const state5 = router.getStateForAction(
{
type: NavigationActions.NAVIGATE,
routeName: 'Grault',
},
state4
);
const replacedState = router.getStateForAction(
StackActions.replace({
routeName: 'Woo',
params: { meaning: 42 },
}),
state5
);
const originalCurrentScreen = state5.routes[1].routes[1].routes[2];
const replacedCurrentScreen = replacedState.routes[1].routes[1].routes[2];
expect(replacedState.routes[1].routes[1].index).toEqual(2);
expect(replacedState.routes[1].routes[1].routes.length).toEqual(3);
expect(replacedCurrentScreen.key).not.toEqual(originalCurrentScreen.key);
expect(replacedCurrentScreen.routeName).not.toEqual(
originalCurrentScreen.routeName
);
expect(replacedCurrentScreen.routeName).toEqual('Woo');
expect(replacedCurrentScreen.params.meaning).toEqual(42);
});
test('Handles push transition logic with completion action', () => {
const FooScreen = () => <div />;
const BarScreen = () => <div />;
@@ -989,6 +1073,43 @@ describe('StackRouter', () => {
expect(state3 && state3.isTransitioning).toEqual(false);
});
test('Back action parent is prioritized over inactive child routers', () => {
const Bar = () => <div />;
Bar.router = StackRouter({
baz: { screen: () => <div /> },
qux: { screen: () => <div /> },
});
const TestRouter = StackRouter({
foo: { screen: () => <div /> },
bar: { screen: Bar },
boo: { screen: () => <div /> },
});
const state = {
key: 'top',
index: 3,
routes: [
{ routeName: 'foo', key: 'f' },
{
routeName: 'bar',
key: 'b',
index: 1,
routes: [
{ routeName: 'baz', key: 'bz' },
{ routeName: 'qux', key: 'bx' },
],
},
{ routeName: 'foo', key: 'f1' },
{ routeName: 'boo', key: 'z' },
],
};
const testState = TestRouter.getStateForAction(
{ type: NavigationActions.BACK },
state
);
expect(testState.index).toBe(2);
expect(testState.routes[1].index).toBe(1);
});
test('Handle basic stack logic for components with router', () => {
const FooScreen = () => <div />;
const BarScreen = () => <div />;
@@ -1047,6 +1168,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,400 +1797,164 @@ 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',
test('Handles deep navigate completion action', () => {
const LeafScreen = () => <div />;
const FooScreen = () => <div />;
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 = () => <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', () => {
const LeafScreen = () => <div />;
const FooScreen = () => <div />;
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 = () => <div />;
const NestedStack = () => <div />;
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 = () => <div />;
const NestedStack = () => <div />;
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 = () => <div />;
const NestedStack = () => <div />;
const OtherNestedStack = () => <div />;
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 = () => <div />;
const NestedStack = () => <div />;
const OtherNestedStack = () => <div />;
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');
});
});

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

@@ -9,8 +9,8 @@ import {
View,
I18nManager,
ViewPropTypes,
MaskedViewIOS,
} from 'react-native';
import { MaskedViewIOS } from '../../PlatformHelpers';
import SafeAreaView from 'react-native-safe-area-view';
import HeaderTitle from './HeaderTitle';
@@ -473,6 +473,18 @@ class Header extends React.PureComponent {
flexShrink,
flexBasis,
flexWrap,
position,
padding,
paddingHorizontal,
paddingRight,
paddingLeft,
// paddingVertical,
// paddingTop,
// paddingBottom,
top,
right,
bottom,
left,
...safeHeaderStyle
} = headerStyleObj;
@@ -485,6 +497,18 @@ class Header extends React.PureComponent {
warnIfHeaderStyleDefined(flexShrink, 'flexShrink');
warnIfHeaderStyleDefined(flexBasis, 'flexBasis');
warnIfHeaderStyleDefined(flexWrap, 'flexWrap');
warnIfHeaderStyleDefined(padding, 'padding');
warnIfHeaderStyleDefined(position, 'position');
warnIfHeaderStyleDefined(paddingHorizontal, 'paddingHorizontal');
warnIfHeaderStyleDefined(paddingRight, 'paddingRight');
warnIfHeaderStyleDefined(paddingLeft, 'paddingLeft');
// warnIfHeaderStyleDefined(paddingVertical, 'paddingVertical');
// warnIfHeaderStyleDefined(paddingTop, 'paddingTop');
// warnIfHeaderStyleDefined(paddingBottom, 'paddingBottom');
warnIfHeaderStyleDefined(top, 'top');
warnIfHeaderStyleDefined(right, 'right');
warnIfHeaderStyleDefined(bottom, 'bottom');
warnIfHeaderStyleDefined(left, 'left');
}
// TODO: warn if any unsafe styles are provided
@@ -503,7 +527,9 @@ class Header extends React.PureComponent {
<Animated.View
style={[
this.props.layoutInterpolator(this.props),
{ backgroundColor: DEFAULT_BACKGROUND_COLOR },
Platform.OS === 'ios'
? { backgroundColor: DEFAULT_BACKGROUND_COLOR }
: null,
]}
>
<SafeAreaView forceInset={forceInset} style={containerStyles}>
@@ -518,7 +544,11 @@ class Header extends React.PureComponent {
}
function warnIfHeaderStyleDefined(value, styleProp) {
if (value !== undefined) {
if (styleProp === 'position' && value === 'absolute') {
console.warn(
"position: 'absolute' is not supported on headerStyle. If you would like to render content under the header, use the headerTransparent navigationOption."
);
} else if (value !== undefined) {
console.warn(
`${styleProp} was given a value of ${value}, this has no effect on headerStyle.`
);
@@ -556,6 +586,7 @@ const styles = StyleSheet.create({
left: 0,
right: 0,
...platformContainerStyles,
elevation: 0,
},
header: {
...StyleSheet.absoluteFillObject,

View File

@@ -0,0 +1,57 @@
import React from 'react';
import withNavigation from './withNavigation';
const EventNameToPropName = {
willFocus: 'onWillFocus',
didFocus: 'onDidFocus',
willBlur: 'onWillBlur',
didBlur: 'onDidBlur',
};
const EventNames = Object.keys(EventNameToPropName);
class NavigationEvents extends React.Component {
componentDidMount() {
this.subscriptions = {};
EventNames.forEach(this.addListener);
}
componentDidUpdate(prevProps) {
EventNames.forEach(eventName => {
const listenerHasChanged =
this.props[EventNameToPropName[eventName]] !==
prevProps[EventNameToPropName[eventName]];
if (listenerHasChanged) {
this.removeListener(eventName);
this.addListener(eventName);
}
});
}
componentWillUnmount() {
EventNames.forEach(this.removeListener);
}
addListener = eventName => {
const listener = this.props[EventNameToPropName[eventName]];
if (listener) {
this.subscriptions[eventName] = this.props.navigation.addListener(
eventName,
listener
);
}
};
removeListener = eventName => {
if (this.subscriptions[eventName]) {
this.subscriptions[eventName].remove();
this.subscriptions[eventName] = undefined;
}
};
render() {
return null;
}
}
export default withNavigation(NavigationEvents);

View File

@@ -0,0 +1,241 @@
import React from 'react';
import { View } from 'react-native';
import renderer from 'react-test-renderer';
import NavigationEvents from '../NavigationEvents';
import { NavigationProvider } from '../NavigationContext';
const createListener = () => payload => {};
// An easy way to create the 4 listeners prop
const createEventListenersProp = () => ({
onWillFocus: createListener(),
onDidFocus: createListener(),
onWillBlur: createListener(),
onDidBlur: createListener(),
});
const createNavigationAndHelpers = () => {
// A little API to spy on subscription remove calls that are performed during the tests
const removeCallsAPI = (() => {
let removeCalls = [];
return {
reset: () => {
removeCalls = [];
},
add: (name, handler) => {
removeCalls.push({ name, handler });
},
checkRemoveCalled: count => {
expect(removeCalls.length).toBe(count);
},
checkRemoveCalledWith: (name, handler) => {
expect(removeCalls).toContainEqual({ name, handler });
},
};
})();
const navigation = {
addListener: jest.fn((name, handler) => {
return {
remove: () => removeCallsAPI.add(name, handler),
};
}),
};
const checkAddListenerCalled = count => {
expect(navigation.addListener).toHaveBeenCalledTimes(count);
};
const checkAddListenerCalledWith = (eventName, handler) => {
expect(navigation.addListener).toHaveBeenCalledWith(eventName, handler);
};
const checkRemoveCalled = count => {
removeCallsAPI.checkRemoveCalled(count);
};
const checkRemoveCalledWith = (eventName, handler) => {
removeCallsAPI.checkRemoveCalledWith(eventName, handler);
};
return {
navigation,
removeCallsAPI,
checkAddListenerCalled,
checkAddListenerCalledWith,
checkRemoveCalled,
checkRemoveCalledWith,
};
};
// We test 2 distinct ways to provide the navigation to the NavigationEvents (prop/context)
const NavigationEventsTestComp = ({
withContext = true,
navigation,
...props
}) => {
if (withContext) {
return (
<NavigationProvider value={navigation}>
<NavigationEvents {...props} />
</NavigationProvider>
);
} else {
return <NavigationEvents navigation={navigation} {...props} />;
}
};
describe('NavigationEvents', () => {
it('add all listeners with navigation prop', () => {
const {
navigation,
checkAddListenerCalled,
checkAddListenerCalledWith,
} = createNavigationAndHelpers();
const eventListenerProps = createEventListenersProp();
const component = renderer.create(
<NavigationEventsTestComp
withContext={false}
navigation={navigation}
{...eventListenerProps}
/>
);
checkAddListenerCalled(4);
checkAddListenerCalledWith('willBlur', eventListenerProps.onWillBlur);
checkAddListenerCalledWith('willFocus', eventListenerProps.onWillFocus);
checkAddListenerCalledWith('didBlur', eventListenerProps.onDidBlur);
checkAddListenerCalledWith('didFocus', eventListenerProps.onDidFocus);
});
it('add all listeners with navigation context', () => {
const {
navigation,
checkAddListenerCalled,
checkAddListenerCalledWith,
} = createNavigationAndHelpers();
const eventListenerProps = createEventListenersProp();
const component = renderer.create(
<NavigationEventsTestComp
withContext={true}
navigation={navigation}
{...eventListenerProps}
/>
);
checkAddListenerCalled(4);
checkAddListenerCalledWith('willBlur', eventListenerProps.onWillBlur);
checkAddListenerCalledWith('willFocus', eventListenerProps.onWillFocus);
checkAddListenerCalledWith('didBlur', eventListenerProps.onDidBlur);
checkAddListenerCalledWith('didFocus', eventListenerProps.onDidFocus);
});
it('remove all listeners on unmount', () => {
const {
navigation,
checkRemoveCalled,
checkRemoveCalledWith,
} = createNavigationAndHelpers();
const eventListenerProps = createEventListenersProp();
const component = renderer.create(
<NavigationEventsTestComp
navigation={navigation}
{...eventListenerProps}
/>
);
checkRemoveCalled(0);
component.unmount();
checkRemoveCalled(4);
checkRemoveCalledWith('willBlur', eventListenerProps.onWillBlur);
checkRemoveCalledWith('willFocus', eventListenerProps.onWillFocus);
checkRemoveCalledWith('didBlur', eventListenerProps.onDidBlur);
checkRemoveCalledWith('didFocus', eventListenerProps.onDidFocus);
});
it('add a single listener', () => {
const {
navigation,
checkAddListenerCalled,
checkAddListenerCalledWith,
} = createNavigationAndHelpers();
const listener = createListener();
const component = renderer.create(
<NavigationEventsTestComp navigation={navigation} onDidFocus={listener} />
);
checkAddListenerCalled(1);
checkAddListenerCalledWith('didFocus', listener);
});
it('do not attempt to add/remove stable listeners on update', () => {
const {
navigation,
checkAddListenerCalled,
checkAddListenerCalledWith,
} = createNavigationAndHelpers();
const eventListenerProps = createEventListenersProp();
const component = renderer.create(
<NavigationEventsTestComp
navigation={navigation}
{...eventListenerProps}
/>
);
component.update(
<NavigationEventsTestComp
navigation={navigation}
{...eventListenerProps}
/>
);
component.update(
<NavigationEventsTestComp
navigation={navigation}
{...eventListenerProps}
/>
);
checkAddListenerCalled(4);
checkAddListenerCalledWith('willBlur', eventListenerProps.onWillBlur);
checkAddListenerCalledWith('willFocus', eventListenerProps.onWillFocus);
checkAddListenerCalledWith('didBlur', eventListenerProps.onDidBlur);
checkAddListenerCalledWith('didFocus', eventListenerProps.onDidFocus);
});
it('add, remove and replace (remove+add) listeners on complex updates', () => {
const {
navigation,
checkAddListenerCalled,
checkAddListenerCalledWith,
checkRemoveCalled,
checkRemoveCalledWith,
} = createNavigationAndHelpers();
const eventListenerProps = createEventListenersProp();
const component = renderer.create(
<NavigationEventsTestComp
navigation={navigation}
{...eventListenerProps}
/>
);
checkAddListenerCalled(4);
checkAddListenerCalledWith('willBlur', eventListenerProps.onWillBlur);
checkAddListenerCalledWith('willFocus', eventListenerProps.onWillFocus);
checkAddListenerCalledWith('didBlur', eventListenerProps.onDidBlur);
checkAddListenerCalledWith('didFocus', eventListenerProps.onDidFocus);
checkRemoveCalled(0);
const onWillFocus2 = createListener();
const onDidFocus2 = createListener();
component.update(
<NavigationEventsTestComp
navigation={navigation}
onWillBlur={eventListenerProps.onWillBlur}
onDidBlur={undefined}
onWillFocus={onWillFocus2}
onDidFocus={onDidFocus2}
/>
);
checkAddListenerCalled(6);
checkAddListenerCalledWith('willFocus', onWillFocus2);
checkAddListenerCalledWith('didFocus', onDidFocus2);
checkRemoveCalled(3);
checkRemoveCalledWith('didBlur', eventListenerProps.onDidBlur);
checkRemoveCalledWith('willFocus', eventListenerProps.onWillFocus);
checkRemoveCalledWith('didFocus', eventListenerProps.onDidFocus);
});
});

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"