Give inactive routes in stack opportunity to handle action (#4064)

This commit is contained in:
Brent Vatne 2018-04-26 00:48:55 +00:00 committed by GitHub
parent 42bb1cc317
commit 5fff7ef5c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 219 additions and 18 deletions

View File

@ -27,6 +27,7 @@ import ModalStack from './ModalStack';
import StacksInTabs from './StacksInTabs'; import StacksInTabs from './StacksInTabs';
import StacksOverTabs from './StacksOverTabs'; import StacksOverTabs from './StacksOverTabs';
import StacksWithKeys from './StacksWithKeys'; import StacksWithKeys from './StacksWithKeys';
import InactiveStack from './InactiveStack';
import StackWithCustomHeaderBackImage from './StackWithCustomHeaderBackImage'; import StackWithCustomHeaderBackImage from './StackWithCustomHeaderBackImage';
import SimpleStack from './SimpleStack'; import SimpleStack from './SimpleStack';
import StackWithHeaderPreset from './StackWithHeaderPreset'; import StackWithHeaderPreset from './StackWithHeaderPreset';
@ -45,6 +46,11 @@ const ExampleInfo = {
name: 'Switch between routes', name: 'Switch between routes',
description: 'Jump between routes', description: 'Jump between routes',
}, },
InactiveStack: {
name: 'Navigate idempotently to stacks in inactive routes',
description:
'An inactive route in a stack should be given the opportunity to handle actions',
},
StackWithCustomHeaderBackImage: { StackWithCustomHeaderBackImage: {
name: 'Custom header back image', name: 'Custom header back image',
description: 'Stack with custom header back image', description: 'Stack with custom header back image',
@ -150,6 +156,8 @@ const ExampleRoutes = {
}, },
TabsWithNavigationFocus, TabsWithNavigationFocus,
KeyboardHandlingExample, KeyboardHandlingExample,
// This is commented out because it's rarely useful
// InactiveStack,
}; };
type State = { type State = {

View File

@ -0,0 +1,96 @@
import React from 'react';
import { Button, Text, StatusBar, View, StyleSheet } from 'react-native';
import {
SafeAreaView,
createStackNavigator,
createSwitchNavigator,
NavigationActions,
} from 'react-navigation';
const runSubRoutes = navigation => {
navigation.dispatch(NavigationActions.navigate({ routeName: 'First2' }));
navigation.dispatch(NavigationActions.navigate({ routeName: 'Second2' }));
navigation.dispatch(NavigationActions.navigate({ routeName: 'First2' }));
};
const runSubRoutesWithIntermediate = navigation => {
navigation.dispatch(toFirst1);
navigation.dispatch(toSecond2);
navigation.dispatch(toFirst);
navigation.dispatch(toFirst2);
};
const runSubAction = navigation => {
navigation.dispatch(toFirst2);
navigation.dispatch(toSecond2);
navigation.dispatch(toFirstChild1);
};
const DummyScreen = ({ routeName, navigation, style }) => {
return (
<SafeAreaView
style={[
StyleSheet.absoluteFill,
{
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'white',
},
style,
]}
>
<Text style={{ fontWeight: '800' }}>
{routeName}({navigation.state.key})
</Text>
<View>
<Button title="back" onPress={() => navigation.goBack()} />
<Button title="dismiss" onPress={() => navigation.dismiss()} />
<Button
title="between sub-routes"
onPress={() => runSubRoutes(navigation)}
/>
<Button
title="between sub-routes (with intermediate)"
onPress={() => runSubRoutesWithIntermediate(navigation)}
/>
<Button
title="with sub-action"
onPress={() => runSubAction(navigation)}
/>
</View>
<StatusBar barStyle="default" />
</SafeAreaView>
);
};
const createDummyScreen = routeName => {
const BoundDummyScreen = props => DummyScreen({ ...props, routeName });
return BoundDummyScreen;
};
const toFirst = NavigationActions.navigate({ routeName: 'First' });
const toFirst1 = NavigationActions.navigate({ routeName: 'First1' });
const toFirst2 = NavigationActions.navigate({ routeName: 'First2' });
const toSecond2 = NavigationActions.navigate({ routeName: 'Second2' });
const toFirstChild1 = NavigationActions.navigate({
routeName: 'First',
action: NavigationActions.navigate({ routeName: 'First1' }),
});
export default createStackNavigator(
{
Other: createDummyScreen('Leaf'),
First: createStackNavigator({
First1: createDummyScreen('First1'),
First2: createDummyScreen('First2'),
}),
Second: createStackNavigator({
Second1: createDummyScreen('Second1'),
Second2: createDummyScreen('Second2'),
}),
},
{
headerMode: 'none',
}
);

View File

@ -116,8 +116,23 @@ const StateUtils = {
/** /**
* Replace a route by a key. * Replace a route by a key.
* Note that this moves the index to the positon to where the new route in the * Note that this moves the index to the position to where the new route in the
* stack is at. * stack is at and updates the routes array accordingly.
*/
replaceAndPrune(state, key, route) {
const index = StateUtils.indexOf(state, key);
const replaced = StateUtils.replaceAtIndex(state, index, route);
return {
...replaced,
routes: replaced.routes.slice(0, index + 1),
};
},
/**
* Replace a route by a key.
* Note that this moves the index to the position to where the new route in the
* stack is at. Does not prune the routes.
*/ */
replaceAt(state, key, route) { replaceAt(state, key, route) {
const index = StateUtils.indexOf(state, key); const index = StateUtils.indexOf(state, key);
@ -137,7 +152,7 @@ const StateUtils = {
route.key route.key
); );
if (state.routes[index] === route) { if (state.routes[index] === route && index === state.index) {
return state; return state;
} }
@ -153,7 +168,7 @@ const StateUtils = {
/** /**
* Resets all routes. * Resets all routes.
* Note that this moves the index to the positon to where the last route in the * Note that this moves the index to the position to where the last route in the
* stack is at if the param `index` isn't provided. * stack is at if the param `index` isn't provided.
*/ */
reset(state, routes, index) { reset(state, routes, index) {

View File

@ -202,7 +202,7 @@ describe('StateUtils', () => {
).toEqual(newState); ).toEqual(newState);
}); });
it('Returns the state if index matches the route', () => { it('Returns the state with updated index if route is unchanged but index changes', () => {
const state = { const state = {
index: 0, index: 0,
routes: [{ key: 'a', routeName }, { key: 'b', routeName }], routes: [{ key: 'a', routeName }, { key: 'b', routeName }],
@ -210,7 +210,7 @@ describe('StateUtils', () => {
}; };
expect( expect(
NavigationStateUtils.replaceAtIndex(state, 1, state.routes[1]) NavigationStateUtils.replaceAtIndex(state, 1, state.routes[1])
).toEqual(state); ).toEqual({ ...state, index: 1 });
}); });
// Reset // Reset

View File

@ -143,7 +143,7 @@ exports[`TabNavigator renders successfully 1`] = `
}, },
false, false,
Object { Object {
"flexGrow": 1, "flex": 1,
}, },
] ]
} }
@ -156,6 +156,7 @@ exports[`TabNavigator renders successfully 1`] = `
"alignSelf": "center", "alignSelf": "center",
"height": "100%", "height": "100%",
"justifyContent": "center", "justifyContent": "center",
"minWidth": 30,
"opacity": 1, "opacity": 1,
"position": "absolute", "position": "absolute",
"width": "100%", "width": "100%",
@ -170,6 +171,7 @@ exports[`TabNavigator renders successfully 1`] = `
"alignSelf": "center", "alignSelf": "center",
"height": "100%", "height": "100%",
"justifyContent": "center", "justifyContent": "center",
"minWidth": 30,
"opacity": 0, "opacity": 0,
"position": "absolute", "position": "absolute",
"width": "100%", "width": "100%",

View File

@ -27,6 +27,10 @@ function behavesLikePushAction(action) {
const defaultActionCreators = (route, navStateKey) => ({}); const defaultActionCreators = (route, navStateKey) => ({});
function isResetToRootStack(action) {
return action.type === StackActions.RESET && action.key === null;
}
export default (routeConfigs, stackConfig = {}) => { export default (routeConfigs, stackConfig = {}) => {
// Fail fast on invalid route definitions // Fail fast on invalid route definitions
validateRouteConfigMap(routeConfigs); validateRouteConfigMap(routeConfigs);
@ -223,7 +227,10 @@ export default (routeConfigs, stackConfig = {}) => {
// Check if the focused child scene wants to handle the action, as long as // Check if the focused child scene wants to handle the action, as long as
// it is not a reset to the root stack // it is not a reset to the root stack
if (action.type !== StackActions.RESET || action.key !== null) { if (
!isResetToRootStack(action) &&
action.type !== NavigationActions.NAVIGATE
) {
const keyIndex = action.key const keyIndex = action.key
? StateUtils.indexOf(state, action.key) ? StateUtils.indexOf(state, action.key)
: -1; : -1;
@ -243,6 +250,28 @@ export default (routeConfigs, stackConfig = {}) => {
return StateUtils.replaceAt(state, childRoute.key, route); return StateUtils.replaceAt(state, childRoute.key, route);
} }
} }
} else if (action.type === NavigationActions.NAVIGATE) {
// 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()) {
let childRouter = childRouters[childRoute.routeName];
let debug = action.params && action.params.debug;
if (childRouter) {
const nextRouteState = childRouter.getStateForAction(
action,
childRoute
);
if (nextRouteState === null || nextRouteState !== childRoute) {
return StateUtils.replaceAndPrune(
state,
nextRouteState ? nextRouteState.key : childRoute.key,
nextRouteState ? nextRouteState : childRoute
);
}
}
}
} }
// Handle explicit push navigation action. This must happen after the // Handle explicit push navigation action. This must happen after the

View File

@ -679,12 +679,13 @@ describe('StackRouter', () => {
expect(pushedState).toEqual(null); expect(pushedState).toEqual(null);
}); });
test('Navigate with key is idempotent', () => { test('Navigate with key and without it is idempotent', () => {
const TestRouter = StackRouter({ const TestRouter = StackRouter({
foo: { screen: () => <div /> }, foo: { screen: () => <div /> },
bar: { screen: () => <div /> }, bar: { screen: () => <div /> },
}); });
const initState = TestRouter.getStateForAction(NavigationActions.init()); const initState = TestRouter.getStateForAction(NavigationActions.init());
for (key of ['a', null]) {
const pushedState = TestRouter.getStateForAction( const pushedState = TestRouter.getStateForAction(
NavigationActions.navigate({ routeName: 'bar', key: 'a' }), NavigationActions.navigate({ routeName: 'bar', key: 'a' }),
initState initState
@ -696,6 +697,56 @@ describe('StackRouter', () => {
pushedState pushedState
); );
expect(pushedTwiceState).toEqual(null); expect(pushedTwiceState).toEqual(null);
}
});
// https://github.com/react-navigation/react-navigation/issues/4063
test('Navigate on inactive stackrouter is idempotent', () => {
const FirstChildNavigator = () => <div />;
FirstChildNavigator.router = StackRouter({
First1: () => <div />,
First2: () => <div />,
});
const SecondChildNavigator = () => <div />;
SecondChildNavigator.router = StackRouter({
Second1: () => <div />,
Second2: () => <div />,
});
const router = StackRouter({
Leaf: () => <div />,
First: FirstChildNavigator,
Second: SecondChildNavigator,
});
const state = router.getStateForAction({ type: NavigationActions.INIT });
const first = router.getStateForAction(
NavigationActions.navigate({ routeName: 'First2' }),
state
);
const second = router.getStateForAction(
NavigationActions.navigate({ routeName: 'Second2' }),
first
);
const firstAgain = router.getStateForAction(
NavigationActions.navigate({
routeName: 'First2',
params: { debug: true },
}),
second
);
expect(first.routes.length).toEqual(2);
expect(first.index).toEqual(1);
expect(second.routes.length).toEqual(3);
expect(second.index).toEqual(2);
expect(firstAgain.index).toEqual(1);
expect(firstAgain.routes.length).toEqual(2);
}); });
test('Navigate to current routeName returns null to indicate handled action', () => { test('Navigate to current routeName returns null to indicate handled action', () => {