Compare commits

...

31 Commits

Author SHA1 Message Date
Satyajit Sahoo
7c2b28ae1e chore: publish
- @react-navigation/bottom-tabs@5.7.2
 - @react-navigation/compat@5.2.1
 - @react-navigation/core@5.12.1
 - @react-navigation/devtools@5.1.3
 - @react-navigation/drawer@5.8.6
 - @react-navigation/material-bottom-tabs@5.2.14
 - @react-navigation/material-top-tabs@5.2.14
 - @react-navigation/native@5.7.1
 - @react-navigation/stack@5.7.1
2020-07-19 14:55:27 +02:00
Satyajit Sahoo
af8b27414c fix: make sure new state events are emitted when new navigators mount 2020-07-19 14:52:43 +02:00
Satyajit Sahoo
b2a99c2a88 chore: publish
- @react-navigation/bottom-tabs@5.7.1
2020-07-14 14:05:18 +02:00
Satyajit Sahoo
2f74541811 fix: don't render badge on bottom tabs if not visible. closes #8577 2020-07-14 14:03:16 +02:00
Satyajit Sahoo
cf09f00472 chore: don't repeat comments for expo preview 2020-07-14 13:44:17 +02:00
Satyajit Sahoo
513482425a chore: publish
- @react-navigation/bottom-tabs@5.7.0
 - @react-navigation/compat@5.2.0
 - @react-navigation/core@5.12.0
 - @react-navigation/devtools@5.1.2
 - @react-navigation/drawer@5.8.5
 - @react-navigation/material-bottom-tabs@5.2.13
 - @react-navigation/material-top-tabs@5.2.13
 - @react-navigation/native@5.7.0
 - @react-navigation/routers@5.4.9
 - @react-navigation/stack@5.7.0
2020-07-10 22:40:45 +02:00
Satyajit Sahoo
f4180295bf feat: add a getComponent prop to lazily specify components 2020-07-10 22:33:13 +02:00
Satyajit Sahoo
c665c027a6 fix: tweak border color to match iOS default 2020-07-10 21:54:29 +02:00
Satyajit Sahoo
849e04ab6a fix: fix bottom tab bar to match iOS defaults 2020-07-10 21:54:29 +02:00
Satyajit Sahoo
374b081b1c fix: only remove non-existed routes from tab history. closes #8567 2020-07-10 21:54:29 +02:00
Satyajit Sahoo
96c7b688ce feat: add support for badges to bottom tab bar 2020-07-10 21:54:29 +02:00
Satyajit Sahoo
e63580edbe fix: improve the warning message for non-serializable values 2020-07-10 15:32:18 +02:00
Satyajit Sahoo
eea9860323 refactor: change format to formatter for documentTitle option 2020-07-10 13:07:47 +02:00
Satyajit Sahoo
13c9d1e281 feat: add a hook to update document title 2020-07-10 13:00:45 +02:00
osdnk
8f5286ef50 fix: ensure correct document title after going back on Chrome 2020-07-10 11:45:03 +02:00
Satyajit Sahoo
a255e350f9 fix: fix options event being emitted incorrectly (#8559) 2020-07-09 15:47:27 +02:00
Satyajit Sahoo
7a74bdb24e test: add test for merging params on navigation 2020-07-09 11:48:48 +02:00
Satyajit Sahoo
7c3a0a0f23 fix: mark some types as read-only 2020-07-09 11:07:14 +02:00
Satyajit Sahoo
bddb1f0046 chore: fix uploading test coverage to codecov 2020-07-08 12:49:03 +02:00
Satyajit Sahoo
c1521e81e8 chore: fix the lint script to be windows compatible 2020-07-02 16:53:35 +02:00
Satyajit Sahoo
bce6c4fc3b chore: tweak types in the example 2020-07-02 16:52:45 +02:00
Satyajit Sahoo
6925e92dc3 feat: add a beforeRemove event
A lot of times, we want to prompt before leaving a screen if we have unsaved changes. Currently, we need to handle multiple cases to prevent this:

- Disable swipe gestures
- Override the back button in header
- Override the hardware back button on Android

This PR adds a new event which is emitted before a screen gets removed, and the developer has a chance to ask the user before closing the screen.

Example:

```js
React.useEffect(
  () =>
    navigation.addListener('beforeRemove', (e) => {
      if (!hasUnsavedChanges) {
        return;
      }

      e.preventDefault();

      Alert.alert(
        'Discard changes?',
        'You have unsaved changes. Are you sure to discard them and leave the screen?',
        [
          { text: "Don't leave", style: 'cancel', onPress: () => {} },
          {
            text: 'Discard',
            style: 'destructive',
            onPress: () => navigation.dispatch(e.data.action),
          },
        ]
      );
    }),
  [navigation, hasUnsavedChanges]
);
```
2020-07-02 14:32:31 +02:00
Satyajit Sahoo
1801a13323 fix: avoid error setting warning for devtools migration. closes #8534 2020-07-02 14:14:56 +02:00
Satyajit Sahoo
9671c76c51 fix: fix bubbling actions to correct target when specified 2020-07-01 21:37:38 +02:00
Satyajit Sahoo
ec840692ec refactor: make state getter hook more generic 2020-07-01 20:41:20 +02:00
Satyajit Sahoo
1cae93331d refactor: consolidate action and focus listeners 2020-07-01 20:10:38 +02:00
Satyajit Sahoo
4edc2a64e2 chore: limit number of jest workers on circle ci 2020-06-30 18:34:43 +02:00
Satyajit Sahoo
75c99b5a12 chore: add missing babel-loader 2020-06-30 18:23:04 +02:00
Satyajit Sahoo
9ba2f84d18 test: add basic unit tests for all navigators 2020-06-30 16:14:52 +02:00
Satyajit Sahoo
2477db47a0 chore: publish
- @react-navigation/bottom-tabs@5.6.1
 - @react-navigation/compat@5.1.28
 - @react-navigation/core@5.11.1
 - @react-navigation/devtools@5.1.1
 - @react-navigation/drawer@5.8.4
 - @react-navigation/material-bottom-tabs@5.2.12
 - @react-navigation/material-top-tabs@5.2.12
 - @react-navigation/native@5.6.1
 - @react-navigation/stack@5.6.2
2020-06-25 17:31:40 +02:00
Satyajit Sahoo
d1210a861b fix: fix error with type definitions. closes #8511 2020-06-25 17:27:48 +02:00
92 changed files with 3162 additions and 667 deletions

View File

@@ -52,10 +52,10 @@ jobs:
- attach_project
- run:
name: Run unit tests
command: yarn test --coverage
command: yarn test --maxWorkers=2 --coverage
- run:
name: Upload test coverage
command: cat ./coverage/lcov.info | ./node_modules/.bin/codecov
command: yarn codecov
- store_artifacts:
path: coverage
destination: coverage

View File

@@ -1,7 +1,9 @@
{
"extends": "satya164",
"settings": {
"react": { "version": "16" },
"react": {
"version": "16"
},
"import/core-modules": [
"@react-navigation/core",
"@react-navigation/native",
@@ -15,5 +17,11 @@
"@react-navigation/devtools"
]
},
"env": { "browser": true, "node": true }
"env": {
"browser": true,
"node": true
},
"rules": {
"react/no-unused-prop-types": "off"
}
}

View File

@@ -47,9 +47,21 @@ jobs:
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const body = 'The Expo app for the example from this branch is ready!\n\n[expo.io/${{ steps.expo.outputs.path }}](https://expo.io/${{ steps.expo.outputs.path }})\n\n<a href="https://exp.host/${{ steps.expo.outputs.path }}"><img src="https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=exp://exp.host/${{ steps.expo.outputs.path }}" height="200px" width="200px"></a>';
const comments = await github.issues.listComments({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
});
if (comments.some(comment => comment.body === body)) {
return;
}
github.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'The Expo app for the example from this branch is ready!\n\n[expo.io/${{ steps.expo.outputs.path }}](https://expo.io/${{ steps.expo.outputs.path }})\n\n<a href="https://exp.host/${{ steps.expo.outputs.path }}"><img src="https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=exp://exp.host/${{ steps.expo.outputs.path }}" height="200px" width="200px"></a>'
body
})

View File

@@ -1,7 +1,7 @@
name: Check versions
on:
issues:
types: [opened]
types: [opened, edited]
jobs:
check-versions:

View File

@@ -1,22 +1,3 @@
module.exports = {
presets: [
[
'@babel/preset-env',
{
useBuiltIns: 'usage',
corejs: 3,
targets: {
node: 'current',
},
},
],
'@babel/preset-react',
'@babel/preset-typescript',
],
plugins: [
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-optional-chaining',
'@babel/transform-flow-strip-types',
'@babel/plugin-proposal-nullish-coalescing-operator',
],
presets: ['module:metro-react-native-babel-preset'],
};

View File

@@ -44,6 +44,7 @@
"@types/react": "^16.9.36",
"@types/react-dom": "^16.9.8",
"@types/react-native": "^0.62.7",
"babel-loader": "^8.1.0",
"babel-plugin-module-resolver": "^4.0.0",
"babel-preset-expo": "^8.2.1",
"cheerio": "^1.0.0-rc.3",

View File

@@ -5,7 +5,7 @@ import { useTheme, ParamListBase } from '@react-navigation/native';
import {
createStackNavigator,
HeaderBackButton,
StackNavigationProp,
StackScreenProps,
} from '@react-navigation/stack';
type AuthStackParams = {
@@ -81,10 +81,6 @@ const HomeScreen = () => {
const SimpleStack = createStackNavigator<AuthStackParams>();
type Props = {
navigation: StackNavigationProp<ParamListBase>;
};
type State = {
isLoading: boolean;
isSignout: boolean;
@@ -96,7 +92,9 @@ type Action =
| { type: 'SIGN_IN'; token: string }
| { type: 'SIGN_OUT' };
export default function SimpleStackScreen({ navigation }: Props) {
export default function SimpleStackScreen({
navigation,
}: StackScreenProps<ParamListBase>) {
const [state, dispatch] = React.useReducer<React.Reducer<State, Action>>(
(prevState, action) => {
switch (action.type) {
@@ -135,9 +133,11 @@ export default function SimpleStackScreen({ navigation }: Props) {
return () => clearTimeout(timer);
}, []);
navigation.setOptions({
headerShown: false,
});
React.useLayoutEffect(() => {
navigation.setOptions({
headerShown: false,
});
}, [navigation]);
const authContext = React.useMemo(
() => ({
@@ -147,6 +147,10 @@ export default function SimpleStackScreen({ navigation }: Props) {
[]
);
if (state.isLoading) {
return <SplashScreen />;
}
return (
<AuthContext.Provider value={authContext}>
<SimpleStack.Navigator
@@ -156,13 +160,7 @@ export default function SimpleStackScreen({ navigation }: Props) {
),
}}
>
{state.isLoading ? (
<SimpleStack.Screen
name="Splash"
component={SplashScreen}
options={{ title: 'Auth Flow' }}
/>
) : state.userToken === undefined ? (
{state.userToken === undefined ? (
<SimpleStack.Screen
name="SignIn"
options={{

View File

@@ -9,7 +9,7 @@ import {
import type { StackScreenProps } from '@react-navigation/stack';
import {
createBottomTabNavigator,
BottomTabNavigationProp,
BottomTabScreenProps,
} from '@react-navigation/bottom-tabs';
import TouchableBounce from '../Shared/TouchableBounce';
import Albums from '../Shared/Albums';
@@ -36,9 +36,7 @@ const scrollEnabled = Platform.select({ web: true, default: false });
const AlbumsScreen = ({
navigation,
}: {
navigation: BottomTabNavigationProp<BottomTabParams>;
}) => {
}: BottomTabScreenProps<BottomTabParams>) => {
return (
<ScrollView>
<View style={styles.buttons}>
@@ -99,6 +97,7 @@ export default function BottomTabsScreen({
options={{
tabBarLabel: 'Chat',
tabBarIcon: getTabBarIcon('message-reply'),
tabBarBadge: 2,
}}
/>
<BottomTabs.Screen

View File

@@ -8,6 +8,7 @@ import {
import {
createStackNavigator,
StackNavigationProp,
StackScreenProps,
} from '@react-navigation/stack';
import Article from '../Shared/Article';
import Albums from '../Shared/Albums';
@@ -126,8 +127,8 @@ const CompatStack = createCompatStackNavigator<
StackNavigationProp<NestedStackParams>
>(
{
Feed: FeedScreen,
Article: ArticleScreen,
Feed: { getScreen: () => FeedScreen },
Article: { getScreen: () => ArticleScreen },
},
{ navigationOptions: { headerShown: false } }
),
@@ -143,12 +144,12 @@ const CompatStack = createCompatStackNavigator<
export default function CompatStackScreen({
navigation,
}: {
navigation: StackNavigationProp<{}>;
}) {
navigation.setOptions({
headerShown: false,
});
}: StackScreenProps<{}>) {
React.useLayoutEffect(() => {
navigation.setOptions({
headerShown: false,
});
}, [navigation]);
return <CompatStack />;
}

View File

@@ -4,13 +4,12 @@ import { Button } from 'react-native-paper';
import {
Link,
StackActions,
RouteProp,
ParamListBase,
useLinkProps,
} from '@react-navigation/native';
import {
createStackNavigator,
StackNavigationProp,
StackScreenProps,
} from '@react-navigation/stack';
import Article from '../Shared/Article';
import Albums from '../Shared/Albums';
@@ -20,8 +19,6 @@ type SimpleStackParams = {
Albums: undefined;
};
type SimpleStackNavigation = StackNavigationProp<SimpleStackParams>;
const scrollEnabled = Platform.select({ web: true, default: false });
const LinkButton = ({
@@ -45,10 +42,7 @@ const LinkButton = ({
const ArticleScreen = ({
navigation,
route,
}: {
navigation: SimpleStackNavigation;
route: RouteProp<SimpleStackParams, 'Article'>;
}) => {
}: StackScreenProps<SimpleStackParams, 'Article'>) => {
return (
<ScrollView>
<View style={styles.buttons}>
@@ -88,11 +82,7 @@ const ArticleScreen = ({
);
};
const AlbumsScreen = ({
navigation,
}: {
navigation: SimpleStackNavigation;
}) => {
const AlbumsScreen = ({ navigation }: StackScreenProps<SimpleStackParams>) => {
return (
<ScrollView>
<View style={styles.buttons}>
@@ -124,14 +114,15 @@ const AlbumsScreen = ({
const SimpleStack = createStackNavigator<SimpleStackParams>();
type Props = Partial<React.ComponentProps<typeof SimpleStack.Navigator>> & {
navigation: StackNavigationProp<ParamListBase>;
};
type Props = Partial<React.ComponentProps<typeof SimpleStack.Navigator>> &
StackScreenProps<ParamListBase>;
export default function SimpleStackScreen({ navigation, ...rest }: Props) {
navigation.setOptions({
headerShown: false,
});
React.useLayoutEffect(() => {
navigation.setOptions({
headerShown: false,
});
}, [navigation]);
return (
<SimpleStack.Navigator {...rest}>

View File

@@ -8,12 +8,12 @@ import {
} from '@react-navigation/native';
import {
createDrawerNavigator,
DrawerNavigationProp,
DrawerScreenProps,
DrawerContent,
DrawerContentComponentProps,
DrawerContentOptions,
} from '@react-navigation/drawer';
import type { StackNavigationProp } from '@react-navigation/stack';
import type { StackScreenProps } from '@react-navigation/stack';
import Article from '../Shared/Article';
import Albums from '../Shared/Albums';
import NewsFeed from '../Shared/NewsFeed';
@@ -24,8 +24,6 @@ type DrawerParams = {
Albums: undefined;
};
type DrawerNavigation = DrawerNavigationProp<DrawerParams>;
const useIsLargeScreen = () => {
const [dimensions, setDimensions] = React.useState(Dimensions.get('window'));
@@ -60,7 +58,9 @@ const Header = ({
);
};
const ArticleScreen = ({ navigation }: { navigation: DrawerNavigation }) => {
const ArticleScreen = ({
navigation,
}: DrawerScreenProps<DrawerParams, 'Article'>) => {
return (
<>
<Header title="Article" onGoBack={() => navigation.toggleDrawer()} />
@@ -69,7 +69,9 @@ const ArticleScreen = ({ navigation }: { navigation: DrawerNavigation }) => {
);
};
const NewsFeedScreen = ({ navigation }: { navigation: DrawerNavigation }) => {
const NewsFeedScreen = ({
navigation,
}: DrawerScreenProps<DrawerParams, 'NewsFeed'>) => {
return (
<>
<Header title="Feed" onGoBack={() => navigation.toggleDrawer()} />
@@ -78,7 +80,9 @@ const NewsFeedScreen = ({ navigation }: { navigation: DrawerNavigation }) => {
);
};
const AlbumsScreen = ({ navigation }: { navigation: DrawerNavigation }) => {
const AlbumsScreen = ({
navigation,
}: DrawerScreenProps<DrawerParams, 'Albums'>) => {
return (
<>
<Header title="Albums" onGoBack={() => navigation.toggleDrawer()} />
@@ -106,15 +110,16 @@ const CustomDrawerContent = (
const Drawer = createDrawerNavigator<DrawerParams>();
type Props = Partial<React.ComponentProps<typeof Drawer.Navigator>> & {
navigation: StackNavigationProp<ParamListBase>;
};
type Props = Partial<React.ComponentProps<typeof Drawer.Navigator>> &
StackScreenProps<ParamListBase>;
export default function DrawerScreen({ navigation, ...rest }: Props) {
navigation.setOptions({
headerShown: false,
gestureEnabled: false,
});
React.useLayoutEffect(() => {
navigation.setOptions({
headerShown: false,
gestureEnabled: false,
});
}, [navigation]);
const isLargeScreen = useIsLargeScreen();

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import type { ParamListBase } from '@react-navigation/native';
import type { StackNavigationProp } from '@react-navigation/stack';
import type { StackScreenProps } from '@react-navigation/stack';
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
import Albums from '../Shared/Albums';
import Contacts from '../Shared/Contacts';
@@ -14,14 +14,14 @@ type MaterialTopTabParams = {
const MaterialTopTabs = createMaterialTopTabNavigator<MaterialTopTabParams>();
type Props = {
navigation: StackNavigationProp<ParamListBase>;
};
export default function MaterialTopTabsScreen({ navigation }: Props) {
navigation.setOptions({
cardStyle: { flex: 1 },
});
export default function MaterialTopTabsScreen({
navigation,
}: StackScreenProps<ParamListBase>) {
React.useLayoutEffect(() => {
navigation.setOptions({
cardStyle: { flex: 1 },
});
}, [navigation]);
return (
<MaterialTopTabs.Navigator>

View File

@@ -1,10 +1,11 @@
import * as React from 'react';
import { View, StyleSheet, ScrollView, Platform } from 'react-native';
import { Button } from 'react-native-paper';
import type { RouteProp, ParamListBase } from '@react-navigation/native';
import type { ParamListBase } from '@react-navigation/native';
import {
createStackNavigator,
StackNavigationProp,
StackScreenProps,
StackNavigationOptions,
TransitionPresets,
} from '@react-navigation/stack';
import Article from '../Shared/Article';
@@ -15,17 +16,12 @@ type ModalStackParams = {
Albums: undefined;
};
type ModalStackNavigation = StackNavigationProp<ModalStackParams>;
const scrollEnabled = Platform.select({ web: true, default: false });
const ArticleScreen = ({
navigation,
route,
}: {
navigation: ModalStackNavigation;
route: RouteProp<ModalStackParams, 'Article'>;
}) => {
}: StackScreenProps<ModalStackParams, 'Article'>) => {
return (
<ScrollView>
<View style={styles.buttons}>
@@ -52,7 +48,7 @@ const ArticleScreen = ({
);
};
const AlbumsScreen = ({ navigation }: { navigation: ModalStackNavigation }) => {
const AlbumsScreen = ({ navigation }: StackScreenProps<ModalStackParams>) => {
return (
<ScrollView>
<View style={styles.buttons}>
@@ -78,15 +74,16 @@ const AlbumsScreen = ({ navigation }: { navigation: ModalStackNavigation }) => {
const ModalPresentationStack = createStackNavigator<ModalStackParams>();
type Props = {
options?: React.ComponentProps<typeof ModalPresentationStack.Navigator>;
navigation: StackNavigationProp<ParamListBase>;
type Props = StackScreenProps<ParamListBase> & {
options?: StackNavigationOptions;
};
export default function SimpleStackScreen({ navigation, options }: Props) {
navigation.setOptions({
headerShown: false,
});
React.useLayoutEffect(() => {
navigation.setOptions({
headerShown: false,
});
}, [navigation]);
return (
<ModalPresentationStack.Navigator

View File

@@ -1,13 +1,11 @@
import * as React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Button } from 'react-native-paper';
import type { StackNavigationProp } from '@react-navigation/stack';
import type { StackScreenProps } from '@react-navigation/stack';
const NotFoundScreen = ({
navigation,
}: {
navigation: StackNavigationProp<{ Home: undefined }>;
}) => {
}: StackScreenProps<{ Home: undefined }>) => {
return (
<View style={styles.container}>
<Text style={styles.title}>404 Not Found</Text>

View File

@@ -0,0 +1,170 @@
import * as React from 'react';
import {
Alert,
View,
TextInput,
ScrollView,
StyleSheet,
Platform,
} from 'react-native';
import { Button } from 'react-native-paper';
import {
useTheme,
CommonActions,
ParamListBase,
NavigationAction,
} from '@react-navigation/native';
import {
createStackNavigator,
StackScreenProps,
} from '@react-navigation/stack';
import Article from '../Shared/Article';
type PreventRemoveParams = {
Article: { author: string };
Input: undefined;
};
const scrollEnabled = Platform.select({ web: true, default: false });
const ArticleScreen = ({
navigation,
route,
}: StackScreenProps<PreventRemoveParams, 'Article'>) => {
return (
<ScrollView>
<View style={styles.buttons}>
<Button
mode="contained"
onPress={() => navigation.push('Input')}
style={styles.button}
>
Push Input
</Button>
<Button
mode="outlined"
onPress={() => navigation.popToTop()}
style={styles.button}
>
Pop to top
</Button>
</View>
<Article
author={{ name: route.params?.author ?? 'Unknown' }}
scrollEnabled={scrollEnabled}
/>
</ScrollView>
);
};
const InputScreen = ({
navigation,
}: StackScreenProps<PreventRemoveParams, 'Input'>) => {
const [text, setText] = React.useState('');
const { colors } = useTheme();
const hasUnsavedChanges = Boolean(text);
React.useEffect(
() =>
navigation.addListener('beforeRemove', (e) => {
const action: NavigationAction & { payload?: { confirmed?: boolean } } =
e.data.action;
if (!hasUnsavedChanges || action.payload?.confirmed) {
return;
}
e.preventDefault();
Alert.alert(
'Discard changes?',
'You have unsaved changes. Are you sure to discard them and leave the screen?',
[
{ text: "Don't leave", style: 'cancel', onPress: () => {} },
{
text: 'Discard',
style: 'destructive',
onPress: () => navigation.dispatch(action),
},
]
);
}),
[hasUnsavedChanges, navigation]
);
return (
<View style={styles.content}>
<TextInput
autoFocus
style={[
styles.input,
{ backgroundColor: colors.card, color: colors.text },
]}
value={text}
placeholder="Type something…"
onChangeText={setText}
/>
<Button
mode="outlined"
color="tomato"
onPress={() =>
navigation.dispatch({
...CommonActions.goBack(),
payload: { confirmed: true },
})
}
style={styles.button}
>
Discard and go back
</Button>
<Button
mode="outlined"
onPress={() => navigation.push('Article', { author: text })}
style={styles.button}
>
Push Article
</Button>
</View>
);
};
const SimpleStack = createStackNavigator<PreventRemoveParams>();
type Props = StackScreenProps<ParamListBase>;
export default function SimpleStackScreen({ navigation }: Props) {
React.useLayoutEffect(() => {
navigation.setOptions({
headerShown: false,
});
}, [navigation]);
return (
<SimpleStack.Navigator>
<SimpleStack.Screen name="Input" component={InputScreen} />
<SimpleStack.Screen name="Article" component={ArticleScreen} />
</SimpleStack.Navigator>
);
}
const styles = StyleSheet.create({
content: {
flex: 1,
padding: 16,
},
input: {
margin: 8,
padding: 10,
borderRadius: 3,
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(0, 0, 0, 0.08)',
},
buttons: {
flexDirection: 'row',
padding: 8,
},
button: {
margin: 8,
},
});

View File

@@ -1,38 +1,33 @@
import * as React from 'react';
import { View, Platform, StyleSheet, ScrollView } from 'react-native';
import { Button } from 'react-native-paper';
import type { RouteProp, ParamListBase } from '@react-navigation/native';
import type { ParamListBase } from '@react-navigation/native';
import {
createStackNavigator,
StackNavigationProp,
StackScreenProps,
} from '@react-navigation/stack';
import Article from '../Shared/Article';
import Albums from '../Shared/Albums';
import NewsFeed from '../Shared/NewsFeed';
type SimpleStackParams = {
Article: { author: string };
NewsFeed: undefined;
Article: { author: string } | undefined;
NewsFeed: { date: number };
Albums: undefined;
};
type SimpleStackNavigation = StackNavigationProp<SimpleStackParams>;
const scrollEnabled = Platform.select({ web: true, default: false });
const ArticleScreen = ({
navigation,
route,
}: {
navigation: SimpleStackNavigation;
route: RouteProp<SimpleStackParams, 'Article'>;
}) => {
}: StackScreenProps<SimpleStackParams, 'Article'>) => {
return (
<ScrollView>
<View style={styles.buttons}>
<Button
mode="contained"
onPress={() => navigation.replace('NewsFeed')}
onPress={() => navigation.replace('NewsFeed', { date: Date.now() })}
style={styles.button}
>
Replace with feed
@@ -46,7 +41,7 @@ const ArticleScreen = ({
</Button>
</View>
<Article
author={{ name: route.params.author }}
author={{ name: route.params?.author ?? 'Unknown' }}
scrollEnabled={scrollEnabled}
/>
</ScrollView>
@@ -54,10 +49,9 @@ const ArticleScreen = ({
};
const NewsFeedScreen = ({
route,
navigation,
}: {
navigation: SimpleStackNavigation;
}) => {
}: StackScreenProps<SimpleStackParams, 'NewsFeed'>) => {
return (
<ScrollView>
<View style={styles.buttons}>
@@ -76,16 +70,14 @@ const NewsFeedScreen = ({
Go back
</Button>
</View>
<NewsFeed scrollEnabled={scrollEnabled} />
<NewsFeed scrollEnabled={scrollEnabled} date={route.params.date} />
</ScrollView>
);
};
const AlbumsScreen = ({
navigation,
}: {
navigation: SimpleStackNavigation;
}) => {
}: StackScreenProps<SimpleStackParams, 'Albums'>) => {
return (
<ScrollView>
<View style={styles.buttons}>
@@ -111,14 +103,14 @@ const AlbumsScreen = ({
const SimpleStack = createStackNavigator<SimpleStackParams>();
type Props = {
navigation: StackNavigationProp<ParamListBase>;
};
export default function SimpleStackScreen({ navigation }: Props) {
navigation.setOptions({
headerShown: false,
});
export default function SimpleStackScreen({
navigation,
}: StackScreenProps<ParamListBase>) {
React.useLayoutEffect(() => {
navigation.setOptions({
headerShown: false,
});
}, [navigation]);
return (
<SimpleStack.Navigator>
@@ -126,7 +118,7 @@ export default function SimpleStackScreen({ navigation }: Props) {
name="Article"
component={ArticleScreen}
options={({ route }) => ({
title: `Article by ${route.params.author}`,
title: `Article by ${route.params?.author ?? 'Unknown'}`,
})}
initialParams={{ author: 'Gandalf' }}
/>

View File

@@ -9,10 +9,10 @@ import {
} from 'react-native';
import { Button, Appbar } from 'react-native-paper';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import { useTheme, RouteProp, ParamListBase } from '@react-navigation/native';
import { useTheme, ParamListBase } from '@react-navigation/native';
import {
createStackNavigator,
StackNavigationProp,
StackScreenProps,
HeaderBackground,
useHeaderHeight,
Header,
@@ -27,17 +27,12 @@ type SimpleStackParams = {
Albums: undefined;
};
type SimpleStackNavigation = StackNavigationProp<SimpleStackParams>;
const scrollEnabled = Platform.select({ web: true, default: false });
const ArticleScreen = ({
navigation,
route,
}: {
navigation: SimpleStackNavigation;
route: RouteProp<SimpleStackParams, 'Article'>;
}) => {
}: StackScreenProps<SimpleStackParams, 'Article'>) => {
return (
<ScrollView>
<View style={styles.buttons}>
@@ -64,11 +59,7 @@ const ArticleScreen = ({
);
};
const AlbumsScreen = ({
navigation,
}: {
navigation: SimpleStackNavigation;
}) => {
const AlbumsScreen = ({ navigation }: StackScreenProps<SimpleStackParams>) => {
const headerHeight = useHeaderHeight();
return (
@@ -96,9 +87,8 @@ const AlbumsScreen = ({
const SimpleStack = createStackNavigator<SimpleStackParams>();
type Props = Partial<React.ComponentProps<typeof SimpleStack.Navigator>> & {
navigation: StackNavigationProp<ParamListBase>;
};
type Props = Partial<React.ComponentProps<typeof SimpleStack.Navigator>> &
StackScreenProps<ParamListBase>;
function CustomHeader(props: StackHeaderProps) {
const { current, next } = props.scene.progress;
@@ -120,9 +110,11 @@ function CustomHeader(props: StackHeaderProps) {
}
export default function SimpleStackScreen({ navigation, ...rest }: Props) {
navigation.setOptions({
headerShown: false,
});
React.useLayoutEffect(() => {
navigation.setOptions({
headerShown: false,
});
}, [navigation]);
const { colors, dark } = useTheme();

View File

@@ -1,10 +1,10 @@
import * as React from 'react';
import { View, StyleSheet, ScrollView, Platform } from 'react-native';
import { Button, Paragraph } from 'react-native-paper';
import { RouteProp, ParamListBase, useTheme } from '@react-navigation/native';
import { ParamListBase, useTheme } from '@react-navigation/native';
import {
createStackNavigator,
StackNavigationProp,
StackScreenProps,
} from '@react-navigation/stack';
import Article from '../Shared/Article';
@@ -13,17 +13,12 @@ type SimpleStackParams = {
Dialog: undefined;
};
type SimpleStackNavigation = StackNavigationProp<SimpleStackParams>;
const scrollEnabled = Platform.select({ web: true, default: false });
const ArticleScreen = ({
navigation,
route,
}: {
navigation: SimpleStackNavigation;
route: RouteProp<SimpleStackParams, 'Article'>;
}) => {
}: StackScreenProps<SimpleStackParams, 'Article'>) => {
return (
<ScrollView>
<View style={styles.buttons}>
@@ -50,11 +45,7 @@ const ArticleScreen = ({
);
};
const DialogScreen = ({
navigation,
}: {
navigation: SimpleStackNavigation;
}) => {
const DialogScreen = ({ navigation }: StackScreenProps<SimpleStackParams>) => {
const { colors } = useTheme();
return (
@@ -81,14 +72,15 @@ const DialogScreen = ({
const SimpleStack = createStackNavigator<SimpleStackParams>();
type Props = Partial<React.ComponentProps<typeof SimpleStack.Navigator>> & {
navigation: StackNavigationProp<ParamListBase>;
};
type Props = Partial<React.ComponentProps<typeof SimpleStack.Navigator>> &
StackScreenProps<ParamListBase>;
export default function SimpleStackScreen({ navigation, ...rest }: Props) {
navigation.setOptions({
headerShown: false,
});
React.useLayoutEffect(() => {
navigation.setOptions({
headerShown: false,
});
}, [navigation]);
return (
<SimpleStack.Navigator mode="modal" {...rest}>

View File

@@ -18,7 +18,9 @@ import {
} from 'react-native-paper';
import Color from 'color';
type Props = Partial<ScrollViewProps>;
type Props = Partial<ScrollViewProps> & {
date?: number;
};
const Author = () => {
return (

View File

@@ -31,11 +31,11 @@ import {
} from '@react-navigation/native';
import {
createDrawerNavigator,
DrawerNavigationProp,
DrawerScreenProps,
} from '@react-navigation/drawer';
import {
createStackNavigator,
StackNavigationProp,
StackScreenProps,
HeaderStyleInterpolators,
} from '@react-navigation/stack';
import { useReduxDevToolsExtension } from '@react-navigation/devtools';
@@ -53,9 +53,10 @@ import MaterialTopTabsScreen from './Screens/MaterialTopTabs';
import MaterialBottomTabs from './Screens/MaterialBottomTabs';
import NotFound from './Screens/NotFound';
import DynamicTabs from './Screens/DynamicTabs';
import AuthFlow from './Screens/AuthFlow';
import CompatAPI from './Screens/CompatAPI';
import MasterDetail from './Screens/MasterDetail';
import AuthFlow from './Screens/AuthFlow';
import PreventRemove from './Screens/PreventRemove';
import CompatAPI from './Screens/CompatAPI';
import LinkComponent from './Screens/LinkComponent';
YellowBox.ignoreWarnings(['Require cycle:', 'Warning: Async Storage']);
@@ -109,6 +110,10 @@ const SCREENS = {
title: 'Auth Flow',
component: AuthFlow,
},
PreventRemove: {
title: 'Prevent removing screen',
component: PreventRemove,
},
CompatAPI: {
title: 'Compat Layer',
component: CompatAPI,
@@ -272,6 +277,10 @@ export default function App() {
},
}}
fallback={<Text>Loading</Text>}
documentTitle={{
formatter: (options, route) =>
`${options?.title ?? route?.name} - React Navigation Example`,
}}
>
<Drawer.Navigator drawerType={isLargeScreen ? 'permanent' : undefined}>
<Drawer.Screen
@@ -283,11 +292,7 @@ export default function App() {
),
}}
>
{({
navigation,
}: {
navigation: DrawerNavigationProp<RootDrawerParamList>;
}) => (
{({ navigation }: DrawerScreenProps<RootDrawerParamList>) => (
<Stack.Navigator
screenOptions={{
headerStyleInterpolator: HeaderStyleInterpolators.forUIKit,
@@ -308,11 +313,7 @@ export default function App() {
),
}}
>
{({
navigation,
}: {
navigation: StackNavigationProp<RootStackParamList>;
}) => (
{({ navigation }: StackScreenProps<RootStackParamList>) => (
<ScrollView
style={{ backgroundColor: theme.colors.background }}
>
@@ -361,7 +362,7 @@ export default function App() {
<Stack.Screen
key={name}
name={name}
component={SCREENS[name].component}
getComponent={() => SCREENS[name].component}
options={{ title: SCREENS[name].title }}
/>
)

View File

@@ -1,3 +1,21 @@
/* eslint-env jest */
/* eslint-disable import/no-extraneous-dependencies */
import 'react-native-gesture-handler/jestSetup';
jest.mock('react-native-reanimated', () => {
const Reanimated = require('react-native-reanimated/mock');
// The mock for `call` immediately calls the callback which is incorrect
// So we override it with a no-op
Reanimated.default.call = () => {};
return Reanimated;
});
// Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing
jest.mock('react-native/Libraries/Animated/src/NativeAnimatedHelper');
const error = console.error;
console.error = (...args) =>

View File

@@ -17,7 +17,7 @@
},
"author": "Satyajit Sahoo <satyajit.happy@gmail.com> (https://github.com/satya164/), Michał Osadnik <micosa97@gmail.com> (https://github.com/osdnk/)",
"scripts": {
"lint": "eslint --ext '.js,.ts,.tsx' .",
"lint": "eslint \"**/*.{js,ts,tsx}\"",
"typescript": "tsc --noEmit --composite false",
"test": "jest",
"prerelease": "lerna run clean",
@@ -25,24 +25,17 @@
"example": "yarn --cwd example"
},
"devDependencies": {
"@babel/plugin-proposal-class-properties": "^7.10.1",
"@babel/plugin-proposal-optional-chaining": "^7.10.1",
"@babel/preset-env": "^7.10.2",
"@babel/preset-flow": "^7.10.1",
"@babel/preset-react": "^7.10.1",
"@babel/preset-typescript": "^7.10.1",
"@babel/runtime": "^7.10.2",
"@commitlint/config-conventional": "^8.3.4",
"@types/jest": "^26.0.0",
"babel-jest": "^26.0.1",
"codecov": "^3.7.0",
"commitlint": "^8.3.5",
"core-js": "^3.6.5",
"eslint": "^7.2.0",
"eslint-config-satya164": "^3.1.7",
"husky": "^4.2.5",
"jest": "^26.0.1",
"lerna": "^3.22.1",
"metro-react-native-babel-preset": "^0.59.0",
"prettier": "^2.0.5",
"typescript": "^3.9.5"
},
@@ -59,9 +52,6 @@
"jest": {
"testEnvironment": "node",
"testRegex": "/__tests__/.*\\.(test|spec)\\.(js|tsx?)$",
"transform": {
"^.+\\.(js|ts|tsx)$": "babel-jest"
},
"setupFiles": [
"<rootDir>/jest/setup.js"
],

View File

@@ -3,6 +3,49 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.7.2](https://github.com/react-navigation/react-navigation/compare/@react-navigation/bottom-tabs@5.7.1...@react-navigation/bottom-tabs@5.7.2) (2020-07-19)
**Note:** Version bump only for package @react-navigation/bottom-tabs
## [5.7.1](https://github.com/react-navigation/react-navigation/compare/@react-navigation/bottom-tabs@5.7.0...@react-navigation/bottom-tabs@5.7.1) (2020-07-14)
### Bug Fixes
* don't render badge on bottom tabs if not visible. closes [#8577](https://github.com/react-navigation/react-navigation/issues/8577) ([2f74541](https://github.com/react-navigation/react-navigation/commit/2f74541811bac4d36e89c159cd1f4b267063e7f9))
# [5.7.0](https://github.com/react-navigation/react-navigation/compare/@react-navigation/bottom-tabs@5.6.1...@react-navigation/bottom-tabs@5.7.0) (2020-07-10)
### Bug Fixes
* fix bottom tab bar to match iOS defaults ([849e04a](https://github.com/react-navigation/react-navigation/commit/849e04ab6a541fffb490ffdfa9819608b88494f4))
### Features
* add support for badges to bottom tab bar ([96c7b68](https://github.com/react-navigation/react-navigation/commit/96c7b688ce773b3dd1f1cf7775367cd7080c94a2))
## [5.6.1](https://github.com/react-navigation/react-navigation/compare/@react-navigation/bottom-tabs@5.6.0...@react-navigation/bottom-tabs@5.6.1) (2020-06-25)
**Note:** Version bump only for package @react-navigation/bottom-tabs
# [5.6.0](https://github.com/react-navigation/react-navigation/compare/@react-navigation/bottom-tabs@5.5.2...@react-navigation/bottom-tabs@5.6.0) (2020-06-24)

View File

@@ -1,7 +1,7 @@
{
"name": "@react-navigation/bottom-tabs",
"description": "Bottom tab navigator following iOS design guidelines",
"version": "5.6.0",
"version": "5.7.2",
"keywords": [
"react-native-component",
"react-component",
@@ -41,7 +41,7 @@
},
"devDependencies": {
"@react-native-community/bob": "^0.15.1",
"@react-navigation/native": "^5.6.0",
"@react-navigation/native": "^5.7.1",
"@types/color": "^3.0.1",
"@types/react": "^16.9.36",
"@types/react-native": "^0.62.7",
@@ -50,6 +50,7 @@
"react-native": "~0.61.5",
"react-native-safe-area-context": "^1.0.0",
"react-native-screens": "^2.7.0",
"react-native-testing-library": "^2.1.0",
"typescript": "^3.9.5"
},
"peerDependencies": {

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import { View, Text, Button } from 'react-native';
import { render, fireEvent } from 'react-native-testing-library';
import { NavigationContainer, ParamListBase } from '@react-navigation/native';
import { createBottomTabNavigator, BottomTabScreenProps } from '../index';
it('renders a bottom tab navigator with screens', async () => {
const Test = ({ route, navigation }: BottomTabScreenProps<ParamListBase>) => (
<View>
<Text>Screen {route.name}</Text>
<Button onPress={() => navigation.navigate('A')} title="Go to A" />
<Button onPress={() => navigation.navigate('B')} title="Go to B" />
</View>
);
const Tab = createBottomTabNavigator();
const { findByText, queryByText } = render(
<NavigationContainer>
<Tab.Navigator>
<Tab.Screen name="A" component={Test} />
<Tab.Screen name="B" component={Test} />
</Tab.Navigator>
</NavigationContainer>
);
expect(queryByText('Screen A')).not.toBeNull();
expect(queryByText('Screen B')).toBeNull();
fireEvent.press(await findByText('Go to B'));
expect(queryByText('Screen B')).not.toBeNull();
});

View File

@@ -79,6 +79,11 @@ export type BottomTabNavigationOptions = {
size: number;
}) => React.ReactNode;
/**
* Text to show in a badge on the tab icon.
*/
tabBarBadge?: number | string;
/**
* Accessibility label for the tab button. This is read by the screen reader when the user taps the tab.
* It's recommended to set this if you don't have a label for the tab.

View File

@@ -0,0 +1,108 @@
import * as React from 'react';
import { Animated, StyleSheet, StyleProp, TextStyle } from 'react-native';
import color from 'color';
import { useTheme } from '@react-navigation/native';
type Props = {
/**
* Whether the badge is visible
*/
visible: boolean;
/**
* Content of the `Badge`.
*/
children?: string | number;
/**
* Size of the `Badge`.
*/
size?: number;
/**
* Style object for the tab bar container.
*/
style?: Animated.WithAnimatedValue<StyleProp<TextStyle>>;
};
export default function Badge({
visible = true,
size = 18,
children,
style,
...rest
}: Props) {
const [opacity] = React.useState(() => new Animated.Value(visible ? 1 : 0));
const [rendered, setRendered] = React.useState(visible ? true : false);
const theme = useTheme();
React.useEffect(() => {
if (!rendered) {
return;
}
Animated.timing(opacity, {
toValue: visible ? 1 : 0,
duration: 150,
useNativeDriver: true,
}).start(({ finished }) => {
if (finished && !visible) {
setRendered(false);
}
});
}, [opacity, rendered, visible]);
if (visible && !rendered) {
setRendered(true);
}
if (!visible && !rendered) {
return null;
}
// @ts-expect-error: backgroundColor definitely exists
const { backgroundColor = theme.colors.notification, ...restStyle } =
StyleSheet.flatten(style) || {};
const textColor = color(backgroundColor).isLight() ? 'black' : 'white';
const borderRadius = size / 2;
const fontSize = Math.floor((size * 3) / 4);
return (
<Animated.Text
numberOfLines={1}
style={[
{
opacity,
transform: [
{
scale: opacity.interpolate({
inputRange: [0, 1],
outputRange: [0.5, 1],
}),
},
],
backgroundColor,
color: textColor,
fontSize,
lineHeight: size - 1,
height: size,
minWidth: size,
borderRadius,
},
styles.container,
restStyle,
]}
{...rest}
>
{children}
</Animated.Text>
);
}
const styles = StyleSheet.create({
container: {
alignSelf: 'flex-end',
textAlign: 'center',
paddingHorizontal: 4,
overflow: 'hidden',
},
});

View File

@@ -25,7 +25,7 @@ type Props = BottomTabBarProps & {
inactiveTintColor?: string;
};
const DEFAULT_TABBAR_HEIGHT = 50;
const DEFAULT_TABBAR_HEIGHT = 49;
const DEFAULT_MAX_TAB_ITEM_WIDTH = 125;
const useNativeDriver = Platform.OS !== 'web';
@@ -152,6 +152,8 @@ export default function BottomTabBar({
left: safeAreaInsets?.left ?? defaultInsets.left,
};
const paddingBottom = Math.max(insets.bottom - 4, 0);
return (
<Animated.View
style={[
@@ -165,7 +167,7 @@ export default function BottomTabBar({
{
translateY: visible.interpolate({
inputRange: [0, 1],
outputRange: [layout.height + insets.bottom, 0],
outputRange: [layout.height + paddingBottom, 0],
}),
},
],
@@ -174,8 +176,8 @@ export default function BottomTabBar({
position: isTabBarHidden ? 'absolute' : null,
},
{
height: DEFAULT_TABBAR_HEIGHT + insets.bottom,
paddingBottom: insets.bottom,
height: DEFAULT_TABBAR_HEIGHT + paddingBottom,
paddingBottom,
paddingHorizontal: Math.max(insets.left, insets.right),
},
style,
@@ -245,6 +247,7 @@ export default function BottomTabBar({
inactiveBackgroundColor={inactiveBackgroundColor}
button={options.tabBarButton}
icon={options.tabBarIcon}
badge={options.tabBarBadge}
label={label}
showLabel={showLabel}
labelStyle={labelStyle}

View File

@@ -39,6 +39,10 @@ type Props = {
size: number;
color: string;
}) => React.ReactNode;
/**
* Text to show in a badge on the tab icon.
*/
badge?: number | string;
/**
* URL to use for the link to the tab.
*/
@@ -113,6 +117,7 @@ export default function BottomTabBarItem({
route,
label,
icon,
badge,
to,
button = ({
children,
@@ -220,16 +225,14 @@ export default function BottomTabBarItem({
return (
<TabBarIcon
route={route}
size={horizontal ? 17 : 24}
horizontal={horizontal}
badge={badge}
activeOpacity={activeOpacity}
inactiveOpacity={inactiveOpacity}
activeTintColor={activeTintColor}
inactiveTintColor={inactiveTintColor}
renderIcon={icon}
style={[
horizontal ? styles.iconHorizontal : styles.iconVertical,
iconStyle,
]}
style={iconStyle}
/>
);
};
@@ -276,23 +279,17 @@ const styles = StyleSheet.create({
justifyContent: 'center',
flexDirection: 'row',
},
iconVertical: {
flex: 1,
},
iconHorizontal: {
height: '100%',
},
label: {
textAlign: 'center',
backgroundColor: 'transparent',
},
labelBeneath: {
fontSize: 11,
marginBottom: 1.5,
fontSize: 10,
},
labelBeside: {
fontSize: 12,
fontSize: 13,
marginLeft: 20,
marginTop: 3,
},
button: {
display: 'flex',

View File

@@ -1,10 +1,12 @@
import React from 'react';
import { View, StyleSheet, StyleProp, ViewStyle } from 'react-native';
import type { Route } from '@react-navigation/native';
import Badge from './Badge';
type Props = {
route: Route<string>;
size: number;
horizontal: boolean;
badge?: string | number;
activeOpacity: number;
inactiveOpacity: number;
activeTintColor: string;
@@ -18,18 +20,23 @@ type Props = {
};
export default function TabBarIcon({
horizontal,
badge,
activeOpacity,
inactiveOpacity,
activeTintColor,
inactiveTintColor,
renderIcon,
size,
style,
}: Props) {
const size = 25;
// We render the icon twice at the same position on top of each other:
// active and inactive one, so we can fade between them.
return (
<View style={style}>
<View
style={[horizontal ? styles.iconHorizontal : styles.iconVertical, style]}
>
<View style={[styles.icon, { opacity: activeOpacity }]}>
{renderIcon({
focused: true,
@@ -44,6 +51,16 @@ export default function TabBarIcon({
color: inactiveTintColor,
})}
</View>
<Badge
visible={badge != null}
style={[
styles.badge,
horizontal ? styles.badgeHorizontal : styles.badgeVertical,
]}
size={(size * 3) / 4}
>
{badge}
</Badge>
</View>
);
}
@@ -62,4 +79,21 @@ const styles = StyleSheet.create({
// Workaround for react-native >= 0.54 layout bug
minWidth: 25,
},
iconVertical: {
flex: 1,
},
iconHorizontal: {
height: '100%',
marginTop: 3,
},
badge: {
position: 'absolute',
left: 3,
},
badgeVertical: {
top: 3,
},
badgeHorizontal: {
top: 7,
},
});

View File

@@ -3,6 +3,33 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.2.1](https://github.com/react-navigation/react-navigation/compare/@react-navigation/compat@5.2.0...@react-navigation/compat@5.2.1) (2020-07-19)
**Note:** Version bump only for package @react-navigation/compat
# [5.2.0](https://github.com/react-navigation/react-navigation/compare/@react-navigation/compat@5.1.28...@react-navigation/compat@5.2.0) (2020-07-10)
### Features
* add a getComponent prop to lazily specify components ([f418029](https://github.com/react-navigation/react-navigation/commit/f4180295bf22e32c65f6a7ab7089523cb2de58fb))
## [5.1.28](https://github.com/react-navigation/react-navigation/compare/@react-navigation/compat@5.1.27...@react-navigation/compat@5.1.28) (2020-06-25)
**Note:** Version bump only for package @react-navigation/compat
## [5.1.27](https://github.com/react-navigation/react-navigation/compare/@react-navigation/compat@5.1.26...@react-navigation/compat@5.1.27) (2020-06-24)

View File

@@ -1,7 +1,7 @@
{
"name": "@react-navigation/compat",
"description": "Compatibility layer to write navigator definitions in static configuration format",
"version": "5.1.27",
"version": "5.2.1",
"license": "MIT",
"repository": {
"type": "git",
@@ -32,7 +32,7 @@
},
"devDependencies": {
"@react-native-community/bob": "^0.15.1",
"@react-navigation/native": "^5.6.0",
"@react-navigation/native": "^5.7.1",
"@types/react": "^16.9.36",
"react": "~16.9.0",
"typescript": "^3.9.5"

View File

@@ -1,25 +1,17 @@
import * as React from 'react';
import type {
NavigationProp,
ParamListBase,
RouteProp,
} from '@react-navigation/native';
import ScreenPropsContext from './ScreenPropsContext';
import useCompatNavigation from './useCompatNavigation';
type Props<ParamList extends ParamListBase> = {
navigation: NavigationProp<ParamList>;
route: RouteProp<ParamList, string>;
component: React.ComponentType<any>;
type Props = {
getComponent: () => React.ComponentType<any>;
};
function ScreenComponent<ParamList extends ParamListBase>(
props: Props<ParamList>
) {
function CompatScreen({ getComponent }: Props) {
const navigation = useCompatNavigation();
const screenProps = React.useContext(ScreenPropsContext);
const ScreenComponent = getComponent();
return <props.component navigation={navigation} screenProps={screenProps} />;
return <ScreenComponent navigation={navigation} screenProps={screenProps} />;
}
export default React.memo(ScreenComponent);
export default React.memo(CompatScreen);

View File

@@ -142,13 +142,7 @@ export default function createCompatNavigatorFactory<
initialParams={{ ...parentRouteParams, ...initialParams }}
options={screenOptions}
>
{({ navigation, route }) => (
<CompatScreen
navigation={navigation}
route={route}
component={getScreenComponent()}
/>
)}
{() => <CompatScreen getComponent={getScreenComponent} />}
</Pair.Screen>
);
}),

View File

@@ -3,6 +3,49 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.12.1](https://github.com/react-navigation/react-navigation/compare/@react-navigation/core@5.12.0...@react-navigation/core@5.12.1) (2020-07-19)
### Bug Fixes
* make sure new state events are emitted when new navigators mount ([af8b274](https://github.com/react-navigation/react-navigation/commit/af8b27414c8628570d946003f4fdff3341cb8954))
# [5.12.0](https://github.com/react-navigation/react-navigation/compare/@react-navigation/core@5.11.1...@react-navigation/core@5.12.0) (2020-07-10)
### Bug Fixes
* avoid error setting warning for devtools migration. closes [#8534](https://github.com/react-navigation/react-navigation/issues/8534) ([1801a13](https://github.com/react-navigation/react-navigation/commit/1801a13323eff149fb6bc4e3c3f12422b401f178))
* fix bubbling actions to correct target when specified ([9671c76](https://github.com/react-navigation/react-navigation/commit/9671c76c5121aaa64a956e2ca696b2f1712cd6f4))
* fix options event being emitted incorrectly ([#8559](https://github.com/react-navigation/react-navigation/issues/8559)) ([a255e35](https://github.com/react-navigation/react-navigation/commit/a255e350f9a54c6d8e410167c9c8661e70b23779))
* improve the warning message for non-serializable values ([e63580e](https://github.com/react-navigation/react-navigation/commit/e63580edbef8e77239f3dbefc919d1a41723eff1))
* mark some types as read-only ([7c3a0a0](https://github.com/react-navigation/react-navigation/commit/7c3a0a0f23629da0beb956ba5a9689ab965061ce))
### Features
* add a `beforeRemove` event ([6925e92](https://github.com/react-navigation/react-navigation/commit/6925e92dc3e9885e3f552ca5e5eb51ae1521e54e))
* add a getComponent prop to lazily specify components ([f418029](https://github.com/react-navigation/react-navigation/commit/f4180295bf22e32c65f6a7ab7089523cb2de58fb))
## [5.11.1](https://github.com/react-navigation/react-navigation/compare/@react-navigation/core@5.11.0...@react-navigation/core@5.11.1) (2020-06-25)
### Bug Fixes
* fix error with type definitions. closes [#8511](https://github.com/react-navigation/react-navigation/issues/8511) ([d1210a8](https://github.com/react-navigation/react-navigation/commit/d1210a861b37201827c333a5c012c4f0ebd9bb6a))
# [5.11.0](https://github.com/react-navigation/react-navigation/compare/@react-navigation/core@5.10.0...@react-navigation/core@5.11.0) (2020-06-24)

View File

@@ -1,7 +1,7 @@
{
"name": "@react-navigation/core",
"description": "Core utilities for building navigators",
"version": "5.11.0",
"version": "5.12.1",
"keywords": [
"react",
"react-native",
@@ -35,7 +35,7 @@
"clean": "del lib"
},
"dependencies": {
"@react-navigation/routers": "^5.4.8",
"@react-navigation/routers": "^5.4.9",
"escape-string-regexp": "^4.0.0",
"nanoid": "^3.1.9",
"query-string": "^6.13.1",

View File

@@ -12,12 +12,12 @@ import NavigationBuilderContext from './NavigationBuilderContext';
import NavigationStateContext from './NavigationStateContext';
import UnhandledActionContext from './UnhandledActionContext';
import { ScheduleUpdateContext } from './useScheduleUpdate';
import useFocusedListeners from './useFocusedListeners';
import useStateGetters from './useStateGetters';
import useChildListeners from './useChildListeners';
import useKeyedChildListeners from './useKeyedChildListeners';
import useOptionsGetters from './useOptionsGetters';
import useEventEmitter from './useEventEmitter';
import useSyncState from './useSyncState';
import isSerializable from './isSerializable';
import checkSerializable from './checkSerializable';
import type {
NavigationContainerEventMap,
NavigationContainerRef,
@@ -29,22 +29,26 @@ type State = NavigationState | PartialState<NavigationState> | undefined;
const NOT_INITIALIZED_ERROR =
"The 'navigation' object hasn't been initialized yet. This might happen if you don't have a navigator mounted, or if the navigator hasn't finished mounting. See https://reactnavigation.org/docs/navigating-without-navigation-prop#handling-initialization for more details.";
let hasWarnedForSerialization = false;
const serializableWarnings: string[] = [];
/**
* Migration instructions for removal of devtools from core
*/
Object.defineProperty(
global,
'REACT_NAVIGATION_REDUX_DEVTOOLS_EXTENSION_INTEGRATION_ENABLED',
{
set(_) {
console.warn(
"Redux devtools extension integration can be enabled with the '@react-navigation/devtools' package. For more details, see https://reactnavigation.org/docs/devtools"
);
},
}
);
try {
/**
* Migration instructions for removal of devtools from core
*/
Object.defineProperty(
global,
'REACT_NAVIGATION_REDUX_DEVTOOLS_EXTENSION_INTEGRATION_ENABLED',
{
set(_) {
console.warn(
"Redux devtools extension integration can be enabled with the '@react-navigation/devtools' package. For more details, see https://reactnavigation.org/docs/devtools"
);
},
}
);
} catch (e) {
// Ignore
}
/**
* Remove `key` and `routeNames` from the state objects recursively to get partial state.
@@ -123,29 +127,26 @@ const BaseNavigationContainer = React.forwardRef(
navigatorKeyRef.current = key;
}, []);
const {
listeners,
addListener: addFocusedListener,
} = useFocusedListeners();
const { listeners, addListener } = useChildListeners();
const { getStateForRoute, addStateGetter } = useStateGetters();
const { keyedListeners, addKeyedListener } = useKeyedChildListeners();
const dispatch = (
action: NavigationAction | ((state: NavigationState) => NavigationAction)
) => {
if (listeners[0] == null) {
if (listeners.focus[0] == null) {
throw new Error(NOT_INITIALIZED_ERROR);
}
listeners[0]((navigation) => navigation.dispatch(action));
listeners.focus[0]((navigation) => navigation.dispatch(action));
};
const canGoBack = () => {
if (listeners[0] == null) {
if (listeners.focus[0] == null) {
return false;
}
const { result, handled } = listeners[0]((navigation) =>
const { result, handled } = listeners.focus[0]((navigation) =>
navigation.canGoBack()
);
@@ -164,8 +165,8 @@ const BaseNavigationContainer = React.forwardRef(
);
const getRootState = React.useCallback(() => {
return getStateForRoute('root');
}, [getStateForRoute]);
return keyedListeners.getState.root?.();
}, [keyedListeners.getState]);
const getCurrentRoute = React.useCallback(() => {
let state = getRootState();
@@ -213,8 +214,16 @@ const BaseNavigationContainer = React.forwardRef(
[emitter]
);
const lastEmittedOptionsRef = React.useRef<object | undefined>();
const onOptionsChange = React.useCallback(
(options) => {
if (lastEmittedOptionsRef.current === options) {
return;
}
lastEmittedOptionsRef.current = options;
emitter.emit({
type: 'options',
data: { options },
@@ -225,12 +234,12 @@ const BaseNavigationContainer = React.forwardRef(
const builderContext = React.useMemo(
() => ({
addFocusedListener,
addStateGetter,
addListener,
addKeyedListener,
onDispatchAction,
onOptionsChange,
}),
[addFocusedListener, addStateGetter, onDispatchAction, onOptionsChange]
[addListener, addKeyedListener, onDispatchAction, onOptionsChange]
);
const scheduleContext = React.useMemo(
@@ -238,6 +247,10 @@ const BaseNavigationContainer = React.forwardRef(
[scheduleUpdate, flushUpdates]
);
const isInitialRef = React.useRef(true);
const getIsInitial = React.useCallback(() => isInitialRef.current, []);
const context = React.useMemo(
() => ({
state,
@@ -245,29 +258,78 @@ const BaseNavigationContainer = React.forwardRef(
setState,
getKey,
setKey,
getIsInitial,
addOptionsGetter,
}),
[getKey, getState, setKey, setState, state, addOptionsGetter]
[
state,
getState,
setState,
getKey,
setKey,
getIsInitial,
addOptionsGetter,
]
);
const onStateChangeRef = React.useRef(onStateChange);
React.useEffect(() => {
isInitialRef.current = false;
onStateChangeRef.current = onStateChange;
});
React.useEffect(() => {
if (process.env.NODE_ENV !== 'production') {
if (
state !== undefined &&
!isSerializable(state) &&
!hasWarnedForSerialization
) {
hasWarnedForSerialization = true;
if (state !== undefined) {
const result = checkSerializable(state);
console.warn(
"Non-serializable values were found in the navigation state, which can break usage such as persisting and restoring state. This might happen if you passed non-serializable values such as function, class instances etc. in params. If you need to use components with callbacks in your options, you can use 'navigation.setOptions' instead. See https://reactnavigation.org/docs/troubleshooting#i-get-the-warning-non-serializable-values-were-found-in-the-navigation-state for more details."
);
if (!result.serializable) {
const { location, reason } = result;
let path = '';
let pointer: Record<any, any> = state;
let params = false;
for (let i = 0; i < location.length; i++) {
const curr = location[i];
const prev = location[i - 1];
pointer = pointer[curr];
if (!params && curr === 'state') {
continue;
} else if (!params && curr === 'routes') {
if (path) {
path += ' > ';
}
} else if (
!params &&
typeof curr === 'number' &&
prev === 'routes'
) {
path += pointer?.name;
} else if (!params) {
path += ` > ${curr}`;
params = true;
} else {
if (typeof curr === 'number' || /^[0-9]+$/.test(curr)) {
path += `[${curr}]`;
} else if (/^[a-z$_]+$/i.test(curr)) {
path += `.${curr}`;
} else {
path += `[${JSON.stringify(curr)}]`;
}
}
}
const message = `Non-serializable values were found in the navigation state. Check:\n\n${path} (${reason})\n\nThis can break usage such as persisting and restoring state. This might happen if you passed non-serializable values such as function, class instances etc. in params. If you need to use components with callbacks in your options, you can use 'navigation.setOptions' instead. See https://reactnavigation.org/docs/troubleshooting#i-get-the-warning-non-serializable-values-were-found-in-the-navigation-state for more details.`;
if (!serializableWarnings.includes(message)) {
serializableWarnings.push(message);
console.warn(message);
}
}
}
}

View File

@@ -6,6 +6,27 @@ import type {
} from '@react-navigation/routers';
import type { NavigationHelpers } from './types';
export type ListenerMap = {
action: ChildActionListener;
focus: FocusedNavigationListener;
};
export type KeyedListenerMap = {
getState: GetStateListener;
beforeRemove: ChildBeforeRemoveListener;
};
export type AddListener = <T extends keyof ListenerMap>(
type: T,
listener: ListenerMap[T]
) => void;
export type AddKeyedListener = <T extends keyof KeyedListenerMap>(
type: T,
key: string,
listener: KeyedListenerMap[T]
) => void;
export type ChildActionListener = (
action: NavigationAction,
visitedNavigators?: Set<string>
@@ -19,7 +40,9 @@ export type FocusedNavigationListener = <T>(
callback: FocusedNavigationCallback<T>
) => { handled: boolean; result: T };
export type NavigatorStateGetter = () => NavigationState;
export type GetStateListener = () => NavigationState;
export type ChildBeforeRemoveListener = (action: NavigationAction) => boolean;
/**
* Context which holds the required helpers needed to build nested navigators.
@@ -29,11 +52,10 @@ const NavigationBuilderContext = React.createContext<{
action: NavigationAction,
visitedNavigators?: Set<string>
) => boolean;
addActionListener?: (listener: ChildActionListener) => void;
addFocusedListener?: (listener: FocusedNavigationListener) => void;
addListener?: AddListener;
addKeyedListener?: AddKeyedListener;
onRouteFocus?: (key: string) => void;
onDispatchAction: (action: NavigationAction, noop: boolean) => void;
addStateGetter?: (key: string, getter: NavigatorStateGetter) => void;
onOptionsChange: (options: object) => void;
}>({
onDispatchAction: () => undefined,

View File

@@ -13,6 +13,7 @@ export default React.createContext<{
setState: (
state: NavigationState | PartialState<NavigationState> | undefined
) => void;
getIsInitial: () => boolean;
addOptionsGetter?: (
key: string,
getter: () => object | undefined | null
@@ -32,4 +33,7 @@ export default React.createContext<{
get setState(): any {
throw new Error(MISSING_CONTEXT_ERROR);
},
get getIsInitial(): any {
throw new Error(MISSING_CONTEXT_ERROR);
},
});

View File

@@ -9,8 +9,6 @@ import NavigationStateContext from './NavigationStateContext';
import StaticContainer from './StaticContainer';
import EnsureSingleNavigator from './EnsureSingleNavigator';
import useOptionsGetters from './useOptionsGetters';
import NavigationBuilderContext from './NavigationBuilderContext';
import useFocusEffect from './useFocusEffect';
import type { NavigationProp, RouteConfig, EventMapBase } from './types';
type Props<
@@ -45,26 +43,14 @@ export default function SceneView<
options,
}: Props<State, ScreenOptions, EventMap>) {
const navigatorKeyRef = React.useRef<string | undefined>();
const { onOptionsChange } = React.useContext(NavigationBuilderContext);
const getKey = React.useCallback(() => navigatorKeyRef.current, []);
const optionsRef = React.useRef<object | undefined>(options);
const getOptions = React.useCallback(() => optionsRef.current, []);
const { addOptionsGetter, hasAnyChildListener } = useOptionsGetters({
const { addOptionsGetter } = useOptionsGetters({
key: route.key,
getState,
getOptions,
options,
navigation,
});
const optionsChange = React.useCallback(() => {
optionsRef.current = options;
if (!hasAnyChildListener) {
onOptionsChange(options);
}
}, [onOptionsChange, options, hasAnyChildListener]);
useFocusEffect(optionsChange);
const setKey = React.useCallback((key: string) => {
navigatorKeyRef.current = key;
}, []);
@@ -90,6 +76,14 @@ export default function SceneView<
[getState, route.key, setState]
);
const isInitialRef = React.useRef(true);
React.useEffect(() => {
isInitialRef.current = false;
});
const getIsInitial = React.useCallback(() => isInitialRef.current, []);
const context = React.useMemo(
() => ({
state: route.state,
@@ -97,31 +91,36 @@ export default function SceneView<
setState: setCurrentState,
getKey,
setKey,
getIsInitial,
addOptionsGetter,
}),
[
getCurrentState,
getKey,
route.state,
getCurrentState,
setCurrentState,
getKey,
setKey,
getIsInitial,
addOptionsGetter,
]
);
const ScreenComponent = screen.getComponent
? screen.getComponent()
: screen.component;
return (
<NavigationStateContext.Provider value={context}>
<EnsureSingleNavigator>
<StaticContainer
name={screen.name}
// @ts-expect-error: these properties exist on screen, but TS is confused
render={screen.component || screen.children}
render={ScreenComponent || screen.children}
navigation={navigation}
route={route}
>
{'component' in screen && screen.component !== undefined ? (
<screen.component navigation={navigation} route={route} />
) : 'children' in screen && screen.children !== undefined ? (
{ScreenComponent !== undefined ? (
<ScreenComponent navigation={navigation} route={route} />
) : screen.children !== undefined ? (
screen.children({ navigation, route })
) : null}
</StaticContainer>

View File

@@ -1,9 +1,11 @@
import * as React from 'react';
import { act, render } from 'react-native-testing-library';
import type {
import {
DefaultRouterOptions,
NavigationState,
Router,
StackRouter,
TabRouter,
} from '@react-navigation/routers';
import BaseNavigationContainer from '../BaseNavigationContainer';
import NavigationStateContext from '../NavigationStateContext';
@@ -364,6 +366,7 @@ it('handles getRootState', () => {
type: 'test',
});
});
it('emits state events when the state changes', () => {
const TestNavigator = (props: any) => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
@@ -399,6 +402,7 @@ it('emits state events when the state changes', () => {
ref.current?.navigate('bar');
});
expect(listener).toBeCalledTimes(1);
expect(listener.mock.calls[0][0].data.state).toEqual({
type: 'test',
stale: false,
@@ -416,6 +420,7 @@ it('emits state events when the state changes', () => {
ref.current?.navigate('baz', { answer: 42 });
});
expect(listener).toBeCalledTimes(2);
expect(listener.mock.calls[1][0].data.state).toEqual({
type: 'test',
stale: false,
@@ -430,7 +435,9 @@ it('emits state events when the state changes', () => {
});
});
it('emits state events when options change', () => {
it('emits state events when new navigator mounts', () => {
jest.useFakeTimers();
const TestNavigator = (props: any) => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
@@ -443,6 +450,95 @@ it('emits state events when options change', () => {
const ref = React.createRef<NavigationContainerRef>();
const NestedNavigator = () => {
const [isRendered, setIsRendered] = React.useState(false);
React.useEffect(() => {
setTimeout(() => setIsRendered(true), 100);
}, []);
if (!isRendered) {
return null;
}
return (
<TestNavigator>
<Screen name="baz">{() => null}</Screen>
<Screen name="bax">{() => null}</Screen>
</TestNavigator>
);
};
const onStateChange = jest.fn();
const element = (
<BaseNavigationContainer ref={ref} onStateChange={onStateChange}>
<TestNavigator>
<Screen name="foo">{() => null}</Screen>
<Screen name="bar" component={NestedNavigator} />
</TestNavigator>
</BaseNavigationContainer>
);
render(element).update(element);
const listener = jest.fn();
ref.current?.addListener('state', listener);
expect(listener).not.toHaveBeenCalled();
expect(onStateChange).not.toHaveBeenCalled();
act(() => {
jest.runAllTimers();
});
const resultState = {
stale: false,
type: 'test',
index: 0,
key: '10',
routeNames: ['foo', 'bar'],
routes: [
{ key: 'foo', name: 'foo' },
{
key: 'bar',
name: 'bar',
state: {
stale: false,
type: 'test',
index: 0,
key: '11',
routeNames: ['baz', 'bax'],
routes: [
{ key: 'baz', name: 'baz' },
{ key: 'bax', name: 'bax' },
],
},
},
],
};
expect(listener).toBeCalledTimes(1);
expect(listener.mock.calls[0][0].data.state).toEqual(resultState);
expect(onStateChange).toBeCalledTimes(1);
expect(onStateChange).lastCalledWith(resultState);
});
it('emits option events when options change with tab router', () => {
const TestNavigator = (props: any) => {
const { state, descriptors } = useNavigationBuilder(TabRouter, props);
return (
<React.Fragment>
{state.routes.map((route) => descriptors[route.key].render())}
</React.Fragment>
);
};
const ref = React.createRef<NavigationContainerRef>();
const element = (
<BaseNavigationContainer ref={ref}>
<TestNavigator>
@@ -455,7 +551,10 @@ it('emits state events when options change', () => {
<Screen name="baz" options={{ v: 3 }}>
{() => (
<TestNavigator>
<Screen name="foo" options={{ g: 5 }}>
<Screen name="qux" options={{ g: 5 }}>
{() => null}
</Screen>
<Screen name="quxx" options={{ h: 9 }}>
{() => null}
</Screen>
</TestNavigator>
@@ -474,19 +573,105 @@ it('emits state events when options change', () => {
ref.current?.navigate('bar');
});
expect(listener.mock.calls[0][0].data.options).toEqual({
y: 2,
});
expect(listener).toBeCalledTimes(1);
expect(listener.mock.calls[0][0].data.options).toEqual({ y: 2 });
expect(ref.current?.getCurrentOptions()).toEqual({ y: 2 });
ref.current?.removeListener('options', listener);
const listener2 = jest.fn();
ref.current?.addListener('options', listener2);
act(() => {
ref.current?.navigate('baz');
});
expect(listener2).toBeCalledTimes(1);
expect(listener2.mock.calls[0][0].data.options).toEqual({ g: 5 });
expect(ref.current?.getCurrentOptions()).toEqual({ g: 5 });
act(() => {
ref.current?.navigate('quxx');
});
expect(listener2).toBeCalledTimes(2);
expect(listener2.mock.calls[1][0].data.options).toEqual({ h: 9 });
expect(ref.current?.getCurrentOptions()).toEqual({ h: 9 });
});
it('emits option events when options change with stack router', () => {
const TestNavigator = (props: any) => {
const { state, descriptors } = useNavigationBuilder(StackRouter, props);
return (
<React.Fragment>
{state.routes.map((route) => descriptors[route.key].render())}
</React.Fragment>
);
};
const ref = React.createRef<NavigationContainerRef>();
const element = (
<BaseNavigationContainer ref={ref}>
<TestNavigator>
<Screen name="foo" options={{ x: 1 }}>
{() => null}
</Screen>
<Screen name="bar" options={{ y: 2 }}>
{() => null}
</Screen>
<Screen name="baz" options={{ v: 3 }}>
{() => (
<TestNavigator>
<Screen name="qux" options={{ g: 5 }}>
{() => null}
</Screen>
<Screen name="quxx" options={{ h: 9 }}>
{() => null}
</Screen>
</TestNavigator>
)}
</Screen>
</TestNavigator>
</BaseNavigationContainer>
);
const listener = jest.fn();
render(element).update(element);
ref.current?.addListener('options', listener);
act(() => {
ref.current?.navigate('bar');
});
expect(listener).toBeCalledTimes(1);
expect(listener.mock.calls[0][0].data.options).toEqual({ y: 2 });
expect(ref.current?.getCurrentOptions()).toEqual({ y: 2 });
ref.current?.removeListener('options', listener);
const listener2 = jest.fn();
ref.current?.addListener('options', listener2);
act(() => {
ref.current?.navigate('baz');
});
expect(listener2).toBeCalledTimes(1);
expect(listener2.mock.calls[0][0].data.options).toEqual({ g: 5 });
expect(ref.current?.getCurrentOptions()).toEqual({ g: 5 });
act(() => {
ref.current?.navigate('quxx');
});
expect(listener2).toBeCalledTimes(2);
expect(listener2.mock.calls[1][0].data.options).toEqual({ h: 9 });
expect(ref.current?.getCurrentOptions()).toEqual({ h: 9 });
});
it('throws if there is no navigator rendered', () => {

View File

@@ -1,8 +1,8 @@
import isSerializable from '../isSerializable';
import checkSerializable from '../checkSerializable';
it('returns true for serializable object', () => {
expect(
isSerializable({
checkSerializable({
index: 0,
key: '7',
routeNames: ['foo', 'bar'],
@@ -22,12 +22,12 @@ it('returns true for serializable object', () => {
},
],
})
).toBe(true);
).toEqual({ serializable: true });
});
it('returns false for non-serializable object', () => {
expect(
isSerializable({
checkSerializable({
index: 0,
key: '7',
routeNames: ['foo', 'bar'],
@@ -47,7 +47,38 @@ it('returns false for non-serializable object', () => {
},
],
})
).toBe(false);
).toEqual({
serializable: false,
location: ['routes', 0, 'state', 'routes', 0, 'params'],
reason: 'Function',
});
expect(
checkSerializable({
index: 0,
key: '7',
routeNames: ['foo', 'bar'],
routes: [
{
key: 'foo',
name: 'foo',
state: {
index: 0,
key: '8',
routeNames: ['qux', 'lex'],
routes: [
{ key: 'qux', name: 'qux', params: { foo: Symbol('test') } },
{ key: 'lex', name: 'lex' },
],
},
},
],
})
).toEqual({
serializable: false,
location: ['routes', 0, 'state', 'routes', 0, 'params', 'foo'],
reason: 'Symbol(test)',
});
});
it('returns false for circular references', () => {
@@ -59,7 +90,11 @@ it('returns false for circular references', () => {
x.b.b2 = x;
x.c = x.b;
expect(isSerializable(x)).toBe(false);
expect(checkSerializable(x)).toEqual({
serializable: false,
location: ['b', 'b2'],
reason: 'Circular reference',
});
const y: any = [
{
@@ -72,7 +107,11 @@ it('returns false for circular references', () => {
y[0].children[0].parent = y[0];
y[1].extend.home = y[0].children[0];
expect(isSerializable(y)).toBe(false);
expect(checkSerializable(y)).toEqual({
serializable: false,
location: [0, 'children', 0, 'parent'],
reason: 'Circular reference',
});
const z: any = {
name: 'sun',
@@ -81,14 +120,18 @@ it('returns false for circular references', () => {
z.child[0].parent = z;
expect(isSerializable(z)).toBe(false);
expect(checkSerializable(z)).toEqual({
serializable: false,
location: ['child', 0, 'parent'],
reason: 'Circular reference',
});
});
it("doesn't fail if same object used multiple times", () => {
const o = { foo: 'bar' };
expect(
isSerializable({
checkSerializable({
baz: 'bax',
first: o,
second: o,
@@ -96,5 +139,5 @@ it("doesn't fail if same object used multiple times", () => {
b: o,
},
})
).toBe(true);
).toEqual({ serializable: true });
});

View File

@@ -1403,6 +1403,7 @@ it('throws if both children and component are passed', () => {
const element = (
<BaseNavigationContainer>
<TestNavigator>
{/* @ts-ignore */}
<Screen name="foo" component={jest.fn()}>
{jest.fn()}
</Screen>
@@ -1415,6 +1416,48 @@ it('throws if both children and component are passed', () => {
);
});
it('throws if both children and getComponent are passed', () => {
const TestNavigator = (props: any) => {
useNavigationBuilder(MockRouter, props);
return null;
};
const element = (
<BaseNavigationContainer>
<TestNavigator>
{/* @ts-ignore */}
<Screen name="foo" getComponent={jest.fn()}>
{jest.fn()}
</Screen>
</TestNavigator>
</BaseNavigationContainer>
);
expect(() => render(element).update(element)).toThrowError(
"Got both 'getComponent' and 'children' props for the screen 'foo'. You must pass only one of them."
);
});
it('throws if both component and getComponent are passed', () => {
const TestNavigator = (props: any) => {
useNavigationBuilder(MockRouter, props);
return null;
};
const element = (
<BaseNavigationContainer>
<TestNavigator>
{/* @ts-ignore */}
<Screen name="foo" component={jest.fn()} getComponent={jest.fn()} />
</TestNavigator>
</BaseNavigationContainer>
);
expect(() => render(element).update(element)).toThrowError(
"Got both 'component' and 'getComponent' props for the screen 'foo'. You must pass only one of them."
);
});
it('throws descriptive error for undefined screen component', () => {
const TestNavigator = (props: any) => {
useNavigationBuilder(MockRouter, props);
@@ -1430,7 +1473,7 @@ it('throws descriptive error for undefined screen component', () => {
);
expect(() => render(element).update(element)).toThrowError(
"Couldn't find a 'component' or 'children' prop for the screen 'foo'"
"Couldn't find a 'component', 'getComponent' or 'children' prop for the screen 'foo'"
);
});
@@ -1453,6 +1496,25 @@ it('throws descriptive error for invalid screen component', () => {
);
});
it('throws descriptive error for invalid getComponent prop', () => {
const TestNavigator = (props: any) => {
useNavigationBuilder(MockRouter, props);
return null;
};
const element = (
<BaseNavigationContainer>
<TestNavigator>
<Screen name="foo" getComponent={{} as any} />
</TestNavigator>
</BaseNavigationContainer>
);
expect(() => render(element).update(element)).toThrowError(
"Got an invalid value for 'getComponent' prop for the screen 'foo'. It must be a function returning a React Component."
);
});
it('throws descriptive error for invalid children', () => {
const TestNavigator = (props: any) => {
useNavigationBuilder(MockRouter, props);

View File

@@ -1,9 +1,10 @@
import * as React from 'react';
import { render } from 'react-native-testing-library';
import type {
import { act, render } from 'react-native-testing-library';
import {
Router,
DefaultRouterOptions,
NavigationState,
StackRouter,
} from '@react-navigation/routers';
import useNavigationBuilder from '../useNavigationBuilder';
import BaseNavigationContainer from '../BaseNavigationContainer';
@@ -12,8 +13,19 @@ import MockRouter, {
MockActions,
MockRouterKey,
} from './__fixtures__/MockRouter';
import type { NavigationContainerRef } from '../types';
beforeEach(() => (MockRouterKey.current = 0));
jest.mock('nanoid/non-secure', () => {
const m = { nanoid: () => String(++m.__key), __key: 0 };
return m;
});
beforeEach(() => {
MockRouterKey.current = 0;
require('nanoid/non-secure').__key = 0;
});
it("lets parent handle the action if child didn't", () => {
function CurrentRouter(options: DefaultRouterOptions) {
@@ -224,6 +236,147 @@ it("lets children handle the action if parent didn't", () => {
});
});
it('action goes to correct navigator if target is specified', () => {
function CurrentTestRouter(options: DefaultRouterOptions) {
const CurrentMockRouter = MockRouter(options);
const TestRouter: Router<
NavigationState,
MockActions | { type: 'REVERSE' }
> = {
...CurrentMockRouter,
shouldActionChangeFocus() {
return true;
},
getStateForAction(state, action, options) {
if (action.type === 'REVERSE') {
return {
...state,
routes: state.routes.slice().reverse(),
};
}
return CurrentMockRouter.getStateForAction(state, action, options);
},
};
return TestRouter;
}
const ChildNavigator = (props: any) => {
const { state, descriptors } = useNavigationBuilder(
CurrentTestRouter,
props
);
return (
<React.Fragment>
{state.routes.map((route) => descriptors[route.key].render())}
</React.Fragment>
);
};
const ParentNavigator = (props: any) => {
const { state, descriptors } = useNavigationBuilder(
CurrentTestRouter,
props
);
return (
<React.Fragment>
{state.routes.map((route) => descriptors[route.key].render())}
</React.Fragment>
);
};
const TestScreen = (props: any) => {
React.useEffect(() => {
props.navigation.dispatch({ type: 'REVERSE', target: '0' });
}, [props.navigation]);
return null;
};
const initialState = {
stale: false,
type: 'test',
index: 1,
key: '0',
routeNames: ['foo', 'bar', 'baz'],
routes: [
{
key: 'baz',
name: 'baz',
state: {
stale: false,
type: 'test',
index: 0,
key: '1',
routeNames: ['qux', 'lex'],
routes: [
{ key: 'lex', name: 'lex' },
{ key: 'qux', name: 'qux' },
],
},
},
{ key: 'bar', name: 'bar' },
{ key: 'foo', name: 'foo' },
],
};
const onStateChange = jest.fn();
const element = (
<BaseNavigationContainer
initialState={initialState}
onStateChange={onStateChange}
>
<ParentNavigator>
<Screen name="foo">{() => null}</Screen>
<Screen name="bar">{() => null}</Screen>
<Screen name="baz">
{() => (
<ChildNavigator>
<Screen name="qux">{() => null}</Screen>
<Screen name="lex" component={TestScreen} />
</ChildNavigator>
)}
</Screen>
</ParentNavigator>
</BaseNavigationContainer>
);
render(element).update(element);
expect(onStateChange).toBeCalledTimes(1);
expect(onStateChange).toBeCalledWith({
stale: false,
type: 'test',
index: 1,
key: '0',
routeNames: ['foo', 'bar', 'baz'],
routes: [
{ key: 'foo', name: 'foo' },
{ key: 'bar', name: 'bar' },
{
key: 'baz',
name: 'baz',
state: {
stale: false,
type: 'test',
index: 0,
key: '1',
routeNames: ['qux', 'lex'],
routes: [
{ key: 'lex', name: 'lex' },
{ key: 'qux', name: 'qux' },
],
},
},
],
});
});
it("action doesn't bubble if target is specified", () => {
const CurrentParentRouter = MockRouter;
@@ -379,3 +532,649 @@ it('logs error if no navigator handled the action', () => {
spy.mockRestore();
});
it("prevents removing a screen with 'beforeRemove' event", () => {
const TestNavigator = (props: any) => {
const { state, descriptors } = useNavigationBuilder(StackRouter, props);
return (
<React.Fragment>
{state.routes.map((route) => descriptors[route.key].render())}
</React.Fragment>
);
};
const onBeforeRemove = jest.fn();
let shouldPrevent = true;
let shouldContinue = false;
const TestScreen = (props: any) => {
React.useEffect(
() =>
props.navigation.addListener('beforeRemove', (e: any) => {
onBeforeRemove();
if (shouldPrevent) {
e.preventDefault();
if (shouldContinue) {
props.navigation.dispatch(e.data.action);
}
}
}),
[props.navigation]
);
return null;
};
const onStateChange = jest.fn();
const ref = React.createRef<NavigationContainerRef>();
const element = (
<BaseNavigationContainer ref={ref} onStateChange={onStateChange}>
<TestNavigator>
<Screen name="foo">{() => null}</Screen>
<Screen name="bar" component={TestScreen} />
<Screen name="baz">{() => null}</Screen>
</TestNavigator>
</BaseNavigationContainer>
);
render(element);
act(() => ref.current?.navigate('bar'));
expect(onStateChange).toBeCalledTimes(1);
expect(onStateChange).toBeCalledWith({
index: 1,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz'],
routes: [
{ key: 'foo-3', name: 'foo' },
{ key: 'bar-4', name: 'bar' },
],
stale: false,
type: 'stack',
});
act(() => ref.current?.navigate('baz'));
expect(onStateChange).toBeCalledTimes(2);
expect(onStateChange).toBeCalledWith({
index: 2,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz'],
routes: [
{ key: 'foo-3', name: 'foo' },
{ key: 'bar-4', name: 'bar' },
{
key: 'baz-5',
name: 'baz',
},
],
stale: false,
type: 'stack',
});
act(() => ref.current?.navigate('foo'));
expect(onStateChange).toBeCalledTimes(2);
expect(onBeforeRemove).toBeCalledTimes(1);
expect(ref.current?.getRootState()).toEqual({
index: 2,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz'],
routes: [
{ key: 'foo-3', name: 'foo' },
{ key: 'bar-4', name: 'bar' },
{ key: 'baz-5', name: 'baz' },
],
stale: false,
type: 'stack',
});
shouldPrevent = false;
act(() => ref.current?.navigate('foo'));
expect(onStateChange).toBeCalledTimes(3);
expect(onStateChange).toBeCalledWith({
index: 0,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz'],
routes: [{ key: 'foo-3', name: 'foo' }],
stale: false,
type: 'stack',
});
shouldPrevent = true;
shouldContinue = true;
act(() => ref.current?.navigate('bar'));
act(() => ref.current?.navigate('foo'));
expect(onStateChange).toBeCalledTimes(5);
expect(onStateChange).toBeCalledWith({
index: 0,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz'],
routes: [{ key: 'foo-3', name: 'foo' }],
stale: false,
type: 'stack',
});
});
it("prevents removing a child screen with 'beforeRemove' event", () => {
const TestNavigator = (props: any) => {
const { state, descriptors } = useNavigationBuilder(StackRouter, props);
return (
<React.Fragment>
{state.routes.map((route) => descriptors[route.key].render())}
</React.Fragment>
);
};
const onBeforeRemove = jest.fn();
let shouldPrevent = true;
let shouldContinue = false;
const TestScreen = (props: any) => {
React.useEffect(
() =>
props.navigation.addListener('beforeRemove', (e: any) => {
onBeforeRemove();
if (shouldPrevent) {
e.preventDefault();
if (shouldContinue) {
props.navigation.dispatch(e.data.action);
}
}
}),
[props.navigation]
);
return null;
};
const onStateChange = jest.fn();
const ref = React.createRef<NavigationContainerRef>();
const element = (
<BaseNavigationContainer ref={ref} onStateChange={onStateChange}>
<TestNavigator>
<Screen name="foo">{() => null}</Screen>
<Screen name="bar">{() => null}</Screen>
<Screen name="baz">
{() => (
<TestNavigator>
<Screen name="qux" component={TestScreen} />
<Screen name="lex">{() => null}</Screen>
</TestNavigator>
)}
</Screen>
</TestNavigator>
</BaseNavigationContainer>
);
render(element);
act(() => ref.current?.navigate('bar'));
expect(onStateChange).toBeCalledTimes(1);
expect(onStateChange).toBeCalledWith({
index: 1,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz'],
routes: [
{ key: 'foo-3', name: 'foo' },
{ key: 'bar-4', name: 'bar' },
],
stale: false,
type: 'stack',
});
act(() => ref.current?.navigate('baz'));
expect(onStateChange).toBeCalledTimes(2);
expect(onStateChange).toBeCalledWith({
index: 2,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz'],
routes: [
{ key: 'foo-3', name: 'foo' },
{ key: 'bar-4', name: 'bar' },
{
key: 'baz-5',
name: 'baz',
state: {
index: 0,
key: 'stack-7',
routeNames: ['qux', 'lex'],
routes: [{ key: 'qux-8', name: 'qux' }],
stale: false,
type: 'stack',
},
},
],
stale: false,
type: 'stack',
});
act(() => ref.current?.navigate('foo'));
expect(onStateChange).toBeCalledTimes(2);
expect(onBeforeRemove).toBeCalledTimes(1);
expect(ref.current?.getRootState()).toEqual({
index: 2,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz'],
routes: [
{ key: 'foo-3', name: 'foo' },
{ key: 'bar-4', name: 'bar' },
{
key: 'baz-5',
name: 'baz',
state: {
index: 0,
key: 'stack-7',
routeNames: ['qux', 'lex'],
routes: [{ key: 'qux-8', name: 'qux' }],
stale: false,
type: 'stack',
},
},
],
stale: false,
type: 'stack',
});
shouldPrevent = false;
act(() => ref.current?.navigate('foo'));
expect(onStateChange).toBeCalledTimes(3);
expect(onStateChange).toBeCalledWith({
index: 0,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz'],
routes: [{ key: 'foo-3', name: 'foo' }],
stale: false,
type: 'stack',
});
shouldPrevent = true;
shouldContinue = true;
act(() => ref.current?.navigate('bar'));
act(() => ref.current?.navigate('foo'));
expect(onStateChange).toBeCalledTimes(5);
expect(onStateChange).toBeCalledWith({
index: 0,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz'],
routes: [{ key: 'foo-3', name: 'foo' }],
stale: false,
type: 'stack',
});
});
it("prevents removing a grand child screen with 'beforeRemove' event", () => {
const TestNavigator = (props: any) => {
const { state, descriptors } = useNavigationBuilder(StackRouter, props);
return (
<React.Fragment>
{state.routes.map((route) => descriptors[route.key].render())}
</React.Fragment>
);
};
const onBeforeRemove = jest.fn();
let shouldPrevent = true;
let shouldContinue = false;
const TestScreen = (props: any) => {
React.useEffect(
() =>
props.navigation.addListener('beforeRemove', (e: any) => {
onBeforeRemove();
if (shouldPrevent) {
e.preventDefault();
if (shouldContinue) {
props.navigation.dispatch(e.data.action);
}
}
}),
[props.navigation]
);
return null;
};
const onStateChange = jest.fn();
const ref = React.createRef<NavigationContainerRef>();
const element = (
<BaseNavigationContainer ref={ref} onStateChange={onStateChange}>
<TestNavigator>
<Screen name="foo">{() => null}</Screen>
<Screen name="bar">{() => null}</Screen>
<Screen name="baz">
{() => (
<TestNavigator>
<Screen name="qux">
{() => (
<TestNavigator>
<Screen name="lex" component={TestScreen} />
</TestNavigator>
)}
</Screen>
</TestNavigator>
)}
</Screen>
</TestNavigator>
</BaseNavigationContainer>
);
render(element);
act(() => ref.current?.navigate('bar'));
expect(onStateChange).toBeCalledTimes(1);
expect(onStateChange).toBeCalledWith({
index: 1,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz'],
routes: [
{ key: 'foo-3', name: 'foo' },
{ key: 'bar-4', name: 'bar' },
],
stale: false,
type: 'stack',
});
act(() => ref.current?.navigate('baz'));
expect(onStateChange).toBeCalledTimes(2);
expect(onStateChange).toBeCalledWith({
index: 2,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz'],
routes: [
{ key: 'foo-3', name: 'foo' },
{ key: 'bar-4', name: 'bar' },
{
key: 'baz-5',
name: 'baz',
state: {
index: 0,
key: 'stack-7',
routeNames: ['qux'],
routes: [
{
key: 'qux-8',
name: 'qux',
state: {
index: 0,
key: 'stack-10',
routeNames: ['lex'],
routes: [{ key: 'lex-11', name: 'lex' }],
stale: false,
type: 'stack',
},
},
],
stale: false,
type: 'stack',
},
},
],
stale: false,
type: 'stack',
});
act(() => ref.current?.navigate('foo'));
expect(onStateChange).toBeCalledTimes(2);
expect(onBeforeRemove).toBeCalledTimes(1);
expect(ref.current?.getRootState()).toEqual({
index: 2,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz'],
routes: [
{ key: 'foo-3', name: 'foo' },
{ key: 'bar-4', name: 'bar' },
{
key: 'baz-5',
name: 'baz',
state: {
index: 0,
key: 'stack-7',
routeNames: ['qux'],
routes: [
{
key: 'qux-8',
name: 'qux',
state: {
index: 0,
key: 'stack-10',
routeNames: ['lex'],
routes: [{ key: 'lex-11', name: 'lex' }],
stale: false,
type: 'stack',
},
},
],
stale: false,
type: 'stack',
},
},
],
stale: false,
type: 'stack',
});
shouldPrevent = false;
act(() => ref.current?.navigate('foo'));
expect(onStateChange).toBeCalledTimes(3);
expect(onStateChange).toBeCalledWith({
index: 0,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz'],
routes: [{ key: 'foo-3', name: 'foo' }],
stale: false,
type: 'stack',
});
shouldPrevent = true;
shouldContinue = true;
act(() => ref.current?.navigate('bar'));
act(() => ref.current?.navigate('foo'));
expect(onStateChange).toBeCalledTimes(5);
expect(onStateChange).toBeCalledWith({
index: 0,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz'],
routes: [{ key: 'foo-3', name: 'foo' }],
stale: false,
type: 'stack',
});
});
it("prevents removing by multiple screens with 'beforeRemove' event", () => {
const TestNavigator = (props: any) => {
const { state, descriptors } = useNavigationBuilder(StackRouter, props);
return (
<React.Fragment>
{state.routes.map((route) => descriptors[route.key].render())}
</React.Fragment>
);
};
const onBeforeRemove = {
bar: jest.fn(),
baz: jest.fn(),
lex: jest.fn(),
};
const shouldPrevent = {
bar: true,
baz: true,
lex: true,
};
const TestScreen = (props: any) => {
React.useEffect(
() =>
props.navigation.addListener('beforeRemove', (e: any) => {
// @ts-expect-error: we should have the required mocks
onBeforeRemove[props.route.name]();
e.preventDefault();
// @ts-expect-error: we should have the required properties
if (!shouldPrevent[props.route.name]) {
props.navigation.dispatch(e.data.action);
}
}),
[props.navigation, props.route.name]
);
return null;
};
const onStateChange = jest.fn();
const ref = React.createRef<NavigationContainerRef>();
const element = (
<BaseNavigationContainer ref={ref} onStateChange={onStateChange}>
<TestNavigator>
<Screen name="foo">{() => null}</Screen>
<Screen name="bar" component={TestScreen} />
<Screen name="baz" component={TestScreen} />
<Screen name="bax">
{() => (
<TestNavigator>
<Screen name="qux">
{() => (
<TestNavigator>
<Screen name="lex" component={TestScreen} />
</TestNavigator>
)}
</Screen>
</TestNavigator>
)}
</Screen>
</TestNavigator>
</BaseNavigationContainer>
);
render(element);
act(() => {
ref.current?.navigate('bar');
ref.current?.navigate('baz');
ref.current?.navigate('bax');
});
const preventedState = {
index: 3,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz', 'bax'],
routes: [
{ key: 'foo-3', name: 'foo' },
{ key: 'bar-4', name: 'bar' },
{ key: 'baz-5', name: 'baz' },
{
key: 'bax-6',
name: 'bax',
state: {
index: 0,
key: 'stack-8',
routeNames: ['qux'],
routes: [
{
key: 'qux-9',
name: 'qux',
state: {
index: 0,
key: 'stack-11',
routeNames: ['lex'],
routes: [{ key: 'lex-12', name: 'lex' }],
stale: false,
type: 'stack',
},
},
],
stale: false,
type: 'stack',
},
},
],
stale: false,
type: 'stack',
};
expect(onStateChange).toBeCalledTimes(1);
expect(onStateChange).toBeCalledWith(preventedState);
act(() => ref.current?.navigate('foo'));
expect(onStateChange).toBeCalledTimes(1);
expect(onBeforeRemove.lex).toBeCalledTimes(1);
expect(ref.current?.getRootState()).toEqual(preventedState);
shouldPrevent.lex = false;
act(() => ref.current?.navigate('foo'));
expect(onStateChange).toBeCalledTimes(1);
expect(onBeforeRemove.baz).toBeCalledTimes(1);
expect(ref.current?.getRootState()).toEqual(preventedState);
shouldPrevent.baz = false;
act(() => ref.current?.navigate('foo'));
expect(onStateChange).toBeCalledTimes(1);
expect(onBeforeRemove.bar).toBeCalledTimes(1);
expect(ref.current?.getRootState()).toEqual(preventedState);
shouldPrevent.bar = false;
act(() => ref.current?.navigate('foo'));
expect(onStateChange).toBeCalledTimes(2);
expect(onStateChange).toBeCalledWith({
index: 0,
key: 'stack-2',
routeNames: ['foo', 'bar', 'baz', 'bax'],
routes: [{ key: 'foo-3', name: 'foo' }],
stale: false,
type: 'stack',
});
});

View File

@@ -0,0 +1,74 @@
const checkSerializableWithoutCircularReference = (
o: { [key: string]: any },
seen: Set<any>,
location: (string | number)[]
):
| { serializable: true }
| {
serializable: false;
location: (string | number)[];
reason: string;
} => {
if (
o === undefined ||
o === null ||
typeof o === 'boolean' ||
typeof o === 'number' ||
typeof o === 'string'
) {
return { serializable: true };
}
if (
Object.prototype.toString.call(o) !== '[object Object]' &&
!Array.isArray(o)
) {
return {
serializable: false,
location,
reason: typeof o === 'function' ? 'Function' : String(o),
};
}
if (seen.has(o)) {
return {
serializable: false,
reason: 'Circular reference',
location,
};
}
seen.add(o);
if (Array.isArray(o)) {
for (let i = 0; i < o.length; i++) {
const childResult = checkSerializableWithoutCircularReference(
o[i],
new Set<any>(seen),
[...location, i]
);
if (!childResult.serializable) {
return childResult;
}
}
} else {
for (const key in o) {
const childResult = checkSerializableWithoutCircularReference(
o[key],
new Set<any>(seen),
[...location, key]
);
if (!childResult.serializable) {
return childResult;
}
}
}
return { serializable: true };
};
export default function checkSerializable(o: { [key: string]: any }) {
return checkSerializableWithoutCircularReference(o, new Set<any>(), []);
}

View File

@@ -260,6 +260,7 @@ export default function getStateFromPath(
);
if (params) {
// @ts-expect-error: params should be treated as read-only, but we're creating the state here so it doesn't matter
route.params = { ...route.params, ...params };
}

View File

@@ -1,47 +0,0 @@
const isSerializableWithoutCircularReference = (
o: { [key: string]: any },
seen: Set<any>
): boolean => {
if (
o === undefined ||
o === null ||
typeof o === 'boolean' ||
typeof o === 'number' ||
typeof o === 'string'
) {
return true;
}
if (
Object.prototype.toString.call(o) !== '[object Object]' &&
!Array.isArray(o)
) {
return false;
}
if (seen.has(o)) {
return false;
}
seen.add(o);
if (Array.isArray(o)) {
for (const it of o) {
if (!isSerializableWithoutCircularReference(it, new Set<any>(seen))) {
return false;
}
}
} else {
for (const key in o) {
if (!isSerializableWithoutCircularReference(o[key], new Set<any>(seen))) {
return false;
}
}
}
return true;
};
export default function isSerializable(o: { [key: string]: any }) {
return isSerializableWithoutCircularReference(o, new Set<any>());
}

View File

@@ -37,6 +37,7 @@ export type EventMapCore<State extends NavigationState> = {
focus: { data: undefined };
blur: { data: undefined };
state: { data: { state: State } };
beforeRemove: { data: { action: NavigationAction }; canPreventDefault: true };
};
export type EventArg<
@@ -61,7 +62,9 @@ export type EventArg<
preventDefault(): void;
}
: {}) &
(undefined extends Data ? {} : { readonly data: Data });
(undefined extends Data
? { readonly data?: Readonly<Data> }
: { readonly data: Readonly<Data> });
export type EventListenerCallback<
EventMap extends EventMapBase,
@@ -108,7 +111,7 @@ export type EventEmitter<EventMap extends EventMapBase> = {
? { canPreventDefault: true }
: {}) &
(undefined extends EventMap[EventName]['data']
? {}
? { data?: EventMap[EventName]['data'] }
: { data: EventMap[EventName]['data'] })
): EventArg<
EventName,
@@ -276,13 +279,18 @@ export type RouteProp<
RouteName extends keyof ParamList
> = Omit<Route<Extract<RouteName, string>>, 'params'> &
(undefined extends ParamList[RouteName]
? {}
: {
? Readonly<{
/**
* Params for this route
*/
params: ParamList[RouteName];
});
params?: Readonly<ParamList[RouteName]>;
}>
: Readonly<{
/**
* Params for this route
*/
params: Readonly<ParamList[RouteName]>;
}>);
export type CompositeNavigationProp<
A extends NavigationProp<ParamListBase, string, any, any>,
@@ -398,6 +406,16 @@ export type RouteConfig<
* React component to render for this screen.
*/
component: React.ComponentType<any>;
getComponent?: never;
children?: never;
}
| {
/**
* Lazily get a React component to render for this screen.
*/
getComponent: () => React.ComponentType<any>;
component?: never;
children?: never;
}
| {
/**
@@ -407,6 +425,8 @@ export type RouteConfig<
route: RouteProp<ParamList, RouteName>;
navigation: any;
}) => React.ReactNode;
component?: never;
getComponent?: never;
}
);

View File

@@ -1,27 +0,0 @@
import * as React from 'react';
import type { ChildActionListener } from './NavigationBuilderContext';
/**
* Hook which lets child navigators add action listeners.
*/
export default function useChildActionListeners() {
const { current: listeners } = React.useRef<ChildActionListener[]>([]);
const addListener = React.useCallback(
(listener: ChildActionListener) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
listeners.splice(index, 1);
};
},
[listeners]
);
return {
listeners,
addListener,
};
}

View File

@@ -0,0 +1,36 @@
import * as React from 'react';
import type { ListenerMap } from './NavigationBuilderContext';
/**
* Hook which lets child navigators add action listeners.
*/
export default function useChildListeners() {
const { current: listeners } = React.useRef<
{
[K in keyof ListenerMap]: ListenerMap[K][];
}
>({
action: [],
focus: [],
});
const addListener = React.useCallback(
<T extends keyof ListenerMap>(type: T, listener: ListenerMap[T]) => {
// @ts-expect-error: listener should be correct type according to `type`
listeners[type].push(listener);
return () => {
// @ts-expect-error: listener should be correct type according to `type`
const index = listeners[type].indexOf(listener);
listeners[type].splice(index, 1);
};
},
[listeners]
);
return {
listeners,
addListener,
};
}

View File

@@ -7,12 +7,13 @@ import type {
} from '@react-navigation/routers';
import SceneView from './SceneView';
import NavigationBuilderContext, {
ChildActionListener,
FocusedNavigationListener,
NavigatorStateGetter,
AddListener,
AddKeyedListener,
} from './NavigationBuilderContext';
import type { NavigationEventEmitter } from './useEventEmitter';
import useNavigationCache from './useNavigationCache';
import NavigationContext from './NavigationContext';
import NavigationRouteContext from './NavigationRouteContext';
import type {
Descriptor,
NavigationHelpers,
@@ -20,8 +21,6 @@ import type {
RouteProp,
EventMapBase,
} from './types';
import NavigationContext from './NavigationContext';
import NavigationRouteContext from './NavigationRouteContext';
type Options<
State extends NavigationState,
@@ -46,9 +45,8 @@ type Options<
) => boolean;
getState: () => State;
setState: (state: State) => void;
addActionListener: (listener: ChildActionListener) => void;
addFocusedListener: (listener: FocusedNavigationListener) => void;
addStateGetter: (key: string, getter: NavigatorStateGetter) => void;
addListener: AddListener;
addKeyedListener: AddKeyedListener;
onRouteFocus: (key: string) => void;
router: Router<State, NavigationAction>;
emitter: NavigationEventEmitter<any>;
@@ -74,9 +72,8 @@ export default function useDescriptors<
onAction,
getState,
setState,
addActionListener,
addFocusedListener,
addStateGetter,
addListener,
addKeyedListener,
onRouteFocus,
router,
emitter,
@@ -90,21 +87,19 @@ export default function useDescriptors<
() => ({
navigation,
onAction,
addActionListener,
addFocusedListener,
addStateGetter,
addListener,
addKeyedListener,
onRouteFocus,
onDispatchAction,
onOptionsChange,
}),
[
addActionListener,
addFocusedListener,
addStateGetter,
navigation,
onAction,
onDispatchAction,
addListener,
addKeyedListener,
onRouteFocus,
onDispatchAction,
onOptionsChange,
]
);

View File

@@ -1,27 +0,0 @@
import * as React from 'react';
import type { FocusedNavigationListener } from './NavigationBuilderContext';
/**
* Hook which lets child navigators add listeners to be called for focused navigators.
*/
export default function useFocusedListeners() {
const { current: listeners } = React.useRef<FocusedNavigationListener[]>([]);
const addListener = React.useCallback(
(listener: FocusedNavigationListener) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
listeners.splice(index, 1);
};
},
[listeners]
);
return {
listeners,
addListener,
};
}

View File

@@ -18,7 +18,7 @@ export default function useFocusedListenersChildrenAdapter({
navigation,
focusedListeners,
}: Options) {
const { addFocusedListener } = React.useContext(NavigationBuilderContext);
const { addListener } = React.useContext(NavigationBuilderContext);
const listener = React.useCallback(
(callback: FocusedNavigationCallback<any>) => {
@@ -39,8 +39,8 @@ export default function useFocusedListenersChildrenAdapter({
[focusedListeners, navigation]
);
React.useEffect(() => addFocusedListener?.(listener), [
addFocusedListener,
React.useEffect(() => addListener?.('focus', listener), [
addListener,
listener,
]);
}

View File

@@ -0,0 +1,42 @@
import * as React from 'react';
import type { KeyedListenerMap } from './NavigationBuilderContext';
/**
* Hook which lets child navigators add getters to be called for obtaining rehydrated state.
*/
export default function useKeyedChildListeners() {
const { current: keyedListeners } = React.useRef<
{
[K in keyof KeyedListenerMap]: Record<
string,
KeyedListenerMap[K] | undefined
>;
}
>({
getState: {},
beforeRemove: {},
});
const addKeyedListener = React.useCallback(
<T extends keyof KeyedListenerMap>(
type: T,
key: string,
listener: KeyedListenerMap[T]
) => {
// @ts-expect-error: listener should be correct type according to `type`
keyedListeners[type][key] = listener;
return () => {
// @ts-expect-error: listener should be correct type according to `type`
keyedListeners[type][key] = undefined;
};
},
[keyedListeners]
);
return {
keyedListeners,
addKeyedListener,
};
}

View File

@@ -21,8 +21,7 @@ import useNavigationHelpers from './useNavigationHelpers';
import useOnAction from './useOnAction';
import useFocusEvents from './useFocusEvents';
import useOnRouteFocus from './useOnRouteFocus';
import useChildActionListeners from './useChildActionListeners';
import useFocusedListeners from './useFocusedListeners';
import useChildListeners from './useChildListeners';
import useFocusedListenersChildrenAdapter from './useFocusedListenersChildrenAdapter';
import {
DefaultNavigatorOptions,
@@ -31,7 +30,7 @@ import {
EventMapBase,
EventMapCore,
} from './types';
import useStateGetters from './useStateGetters';
import useKeyedChildListeners from './useKeyedChildListeners';
import useOnGetState from './useOnGetState';
import useScheduleUpdate from './useScheduleUpdate';
import useCurrentRender from './useCurrentRender';
@@ -103,7 +102,7 @@ const getRouteConfigsFromChildren = <
if (process.env.NODE_ENV !== 'production') {
configs.forEach((config) => {
const { name, children, component } = config as any;
const { name, children, component, getComponent } = config;
if (typeof name !== 'string' || !name) {
throw new Error(
@@ -113,13 +112,29 @@ const getRouteConfigsFromChildren = <
);
}
if (children != null || component !== undefined) {
if (
children != null ||
component !== undefined ||
getComponent !== undefined
) {
if (children != null && component !== undefined) {
throw new Error(
`Got both 'component' and 'children' props for the screen '${name}'. You must pass only one of them.`
);
}
if (children != null && getComponent !== undefined) {
throw new Error(
`Got both 'getComponent' and 'children' props for the screen '${name}'. You must pass only one of them.`
);
}
if (component !== undefined && getComponent !== undefined) {
throw new Error(
`Got both 'component' and 'getComponent' props for the screen '${name}'. You must pass only one of them.`
);
}
if (children != null && typeof children !== 'function') {
throw new Error(
`Got an invalid value for 'children' prop for the screen '${name}'. It must be a function returning a React Element.`
@@ -132,6 +147,12 @@ const getRouteConfigsFromChildren = <
);
}
if (getComponent !== undefined && typeof getComponent !== 'function') {
throw new Error(
`Got an invalid value for 'getComponent' prop for the screen '${name}'. It must be a function returning a React Component.`
);
}
if (typeof component === 'function' && component.name === 'component') {
// Inline anonymous functions passed in the `component` prop will have the name of the prop
// It's relatively safe to assume that it's not a component since it should also have PascalCase name
@@ -142,7 +163,7 @@ const getRouteConfigsFromChildren = <
}
} else {
throw new Error(
`Couldn't find a 'component' or 'children' prop for the screen '${name}'. This can happen if you passed 'undefined'. You likely forgot to export your component from the file it's defined in, or mixed up default import and named import when importing.`
`Couldn't find a 'component', 'getComponent' or 'children' prop for the screen '${name}'. This can happen if you passed 'undefined'. You likely forgot to export your component from the file it's defined in, or mixed up default import and named import when importing.`
);
}
});
@@ -258,6 +279,7 @@ export default function useNavigationBuilder<
setState,
setKey,
getKey,
getIsInitial,
} = React.useContext(NavigationStateContext);
const [initializedState, isFirstStateInitialization] = React.useMemo(() => {
@@ -351,6 +373,13 @@ export default function useNavigationBuilder<
React.useEffect(() => {
setKey(navigatorKey);
if (!getIsInitial()) {
// If it's not initial render, we need to update the state
// This will make sure that our container gets notifier of state changes due to new mounts
// This is necessary for proper screen tracking, URL updates etc.
setState(nextState);
}
return () => {
// We need to clean up state for this navigator on unmount
// We do it in a timeout because we need to detect if another navigator mounted in the meantime
@@ -430,28 +459,22 @@ export default function useNavigationBuilder<
emitter.emit({ type: 'state', data: { state } });
}, [emitter, state]);
const {
listeners: actionListeners,
addListener: addActionListener,
} = useChildActionListeners();
const { listeners: childListeners, addListener } = useChildListeners();
const {
listeners: focusedListeners,
addListener: addFocusedListener,
} = useFocusedListeners();
const { getStateForRoute, addStateGetter } = useStateGetters();
const { keyedListeners, addKeyedListener } = useKeyedChildListeners();
const onAction = useOnAction({
router,
getState,
setState,
key: route?.key,
listeners: actionListeners,
actionListeners: childListeners.action,
beforeRemoveListeners: keyedListeners.beforeRemove,
routerConfigOptions: {
routeNames,
routeParamList,
},
emitter,
});
const onRouteFocus = useOnRouteFocus({
@@ -470,12 +493,12 @@ export default function useNavigationBuilder<
useFocusedListenersChildrenAdapter({
navigation,
focusedListeners,
focusedListeners: childListeners.focus,
});
useOnGetState({
getState,
getStateForRoute,
getStateListeners: keyedListeners.getState,
});
const descriptors = useDescriptors<State, ScreenOptions, EventMap>({
@@ -487,9 +510,8 @@ export default function useNavigationBuilder<
getState,
setState,
onRouteFocus,
addActionListener,
addFocusedListener,
addStateGetter,
addListener,
addKeyedListener,
router,
emitter,
});

View File

@@ -8,15 +8,21 @@ import type {
} from '@react-navigation/routers';
import NavigationBuilderContext, {
ChildActionListener,
ChildBeforeRemoveListener,
} from './NavigationBuilderContext';
import useOnPreventRemove, { shouldPreventRemove } from './useOnPreventRemove';
import type { NavigationEventEmitter } from './useEventEmitter';
import type { EventMapCore } from './types';
type Options = {
router: Router<NavigationState, NavigationAction>;
key?: string;
getState: () => NavigationState;
setState: (state: NavigationState | PartialState<NavigationState>) => void;
listeners: ChildActionListener[];
actionListeners: ChildActionListener[];
beforeRemoveListeners: Record<string, ChildBeforeRemoveListener | undefined>;
routerConfigOptions: RouterConfigOptions;
emitter: NavigationEventEmitter<EventMapCore<any>>;
};
/**
@@ -33,13 +39,15 @@ export default function useOnAction({
getState,
setState,
key,
listeners,
actionListeners,
beforeRemoveListeners,
routerConfigOptions,
emitter,
}: Options) {
const {
onAction: onActionParent,
onRouteFocus: onRouteFocusParent,
addActionListener: addActionListenerParent,
addListener: addListenerParent,
onDispatchAction,
} = React.useContext(NavigationBuilderContext);
@@ -66,38 +74,56 @@ export default function useOnAction({
visitedNavigators.add(state.key);
if (typeof action.target === 'string' && action.target !== state.key) {
return false;
}
if (typeof action.target !== 'string' || action.target === state.key) {
let result = router.getStateForAction(
state,
action,
routerConfigOptionsRef.current
);
let result = router.getStateForAction(
state,
action,
routerConfigOptionsRef.current
);
// If a target is specified and set to current navigator, the action shouldn't bubble
// So instead of `null`, we use the state object for such cases to signal that action was handled
result =
result === null && action.target === state.key ? state : result;
// If a target is specified and set to current navigator, the action shouldn't bubble
// So instead of `null`, we use the state object for such cases to signal that action was handled
result = result === null && action.target === state.key ? state : result;
if (result !== null) {
onDispatchAction(action, state === result);
if (result !== null) {
onDispatchAction(action, state === result);
if (state !== result) {
const nextRouteKeys = (result.routes as any[]).map(
(route: { key?: string }) => route.key
);
if (state !== result) {
setState(result);
}
const removedRoutes = state.routes.filter(
(route) => !nextRouteKeys.includes(route.key)
);
if (onRouteFocusParent !== undefined) {
// Some actions such as `NAVIGATE` also want to bring the navigated route to focus in the whole tree
// This means we need to focus all of the parent navigators of this navigator as well
const shouldFocus = router.shouldActionChangeFocus(action);
const isPrevented = shouldPreventRemove(
emitter,
beforeRemoveListeners,
removedRoutes,
action
);
if (shouldFocus && key !== undefined) {
onRouteFocusParent(key);
if (isPrevented) {
return true;
}
setState(result);
}
}
return true;
if (onRouteFocusParent !== undefined) {
// Some actions such as `NAVIGATE` also want to bring the navigated route to focus in the whole tree
// This means we need to focus all of the parent navigators of this navigator as well
const shouldFocus = router.shouldActionChangeFocus(action);
if (shouldFocus && key !== undefined) {
onRouteFocusParent(key);
}
}
return true;
}
}
if (onActionParent !== undefined) {
@@ -108,8 +134,8 @@ export default function useOnAction({
}
// If the action wasn't handled by current navigator or a parent navigator, let children handle it
for (let i = listeners.length - 1; i >= 0; i--) {
const listener = listeners[i];
for (let i = actionListeners.length - 1; i >= 0; i--) {
const listener = actionListeners[i];
if (listener(action, visitedNavigators)) {
return true;
@@ -119,19 +145,27 @@ export default function useOnAction({
return false;
},
[
actionListeners,
beforeRemoveListeners,
emitter,
getState,
router,
key,
onActionParent,
onDispatchAction,
onRouteFocusParent,
router,
setState,
key,
listeners,
]
);
React.useEffect(() => addActionListenerParent?.(onAction), [
addActionListenerParent,
useOnPreventRemove({
getState,
emitter,
beforeRemoveListeners,
});
React.useEffect(() => addListenerParent?.('action', onAction), [
addListenerParent,
onAction,
]);

View File

@@ -1,17 +1,21 @@
import * as React from 'react';
import type { NavigationState } from '@react-navigation/routers';
import NavigationBuilderContext from './NavigationBuilderContext';
import NavigationBuilderContext, {
GetStateListener,
} from './NavigationBuilderContext';
import NavigationRouteContext from './NavigationRouteContext';
import isArrayEqual from './isArrayEqual';
export default function useOnGetState({
getStateForRoute,
getState,
}: {
getStateForRoute: (routeName: string) => NavigationState | undefined;
type Options = {
getState: () => NavigationState;
}) {
const { addStateGetter } = React.useContext(NavigationBuilderContext);
getStateListeners: Record<string, GetStateListener | undefined>;
};
export default function useOnGetState({
getState,
getStateListeners,
}: Options) {
const { addKeyedListener } = React.useContext(NavigationBuilderContext);
const route = React.useContext(NavigationRouteContext);
const key = route ? route.key : 'root';
@@ -20,7 +24,7 @@ export default function useOnGetState({
// Avoid returning new route objects if we don't need to
const routes = state.routes.map((route) => {
const childState = getStateForRoute(route.key);
const childState = getStateListeners[route.key]?.();
if (route.state === childState) {
return route;
@@ -34,9 +38,9 @@ export default function useOnGetState({
}
return { ...state, routes };
}, [getState, getStateForRoute]);
}, [getState, getStateListeners]);
React.useEffect(() => {
return addStateGetter?.(key, getRehydratedState);
}, [addStateGetter, getRehydratedState, key]);
return addKeyedListener?.('getState', key, getRehydratedState);
}, [addKeyedListener, getRehydratedState, key]);
}

View File

@@ -0,0 +1,93 @@
import * as React from 'react';
import type {
NavigationState,
Route,
NavigationAction,
} from '@react-navigation/routers';
import NavigationBuilderContext, {
ChildBeforeRemoveListener,
} from './NavigationBuilderContext';
import NavigationRouteContext from './NavigationRouteContext';
import type { NavigationEventEmitter } from './useEventEmitter';
import type { EventMapCore } from './types';
type Options = {
getState: () => NavigationState;
emitter: NavigationEventEmitter<EventMapCore<any>>;
beforeRemoveListeners: Record<string, ChildBeforeRemoveListener | undefined>;
};
const VISITED_ROUTE_KEYS = Symbol('VISITED_ROUTE_KEYS');
export const shouldPreventRemove = (
emitter: NavigationEventEmitter<EventMapCore<any>>,
beforeRemoveListeners: Record<string, ChildBeforeRemoveListener | undefined>,
routes: Route<string>[],
action: NavigationAction
) => {
// Call these in reverse order so last screens handle the event first
const reversedRoutes = [...routes].reverse();
const visitedRouteKeys: Set<string> =
// @ts-expect-error: add this property to mark that we've already emitted this action
action[VISITED_ROUTE_KEYS] ?? new Set<string>();
const beforeRemoveAction = {
...action,
[VISITED_ROUTE_KEYS]: visitedRouteKeys,
};
for (const route of reversedRoutes) {
if (visitedRouteKeys.has(route.key)) {
// Skip if we've already emitted this action for this screen
continue;
}
// First, we need to check if any child screens want to prevent it
const isPrevented = beforeRemoveListeners[route.key]?.(beforeRemoveAction);
if (isPrevented) {
return true;
}
visitedRouteKeys.add(route.key);
const event = emitter.emit({
type: 'beforeRemove',
target: route.key,
data: { action: beforeRemoveAction },
canPreventDefault: true,
});
if (event.defaultPrevented) {
return true;
}
}
return false;
};
export default function useOnPreventRemove({
getState,
emitter,
beforeRemoveListeners,
}: Options) {
const { addKeyedListener } = React.useContext(NavigationBuilderContext);
const route = React.useContext(NavigationRouteContext);
const routeKey = route?.key;
React.useEffect(() => {
if (routeKey) {
return addKeyedListener?.('beforeRemove', routeKey, (action) => {
const state = getState();
return shouldPreventRemove(
emitter,
beforeRemoveListeners,
state.routes,
action
);
});
}
}, [addKeyedListener, beforeRemoveListeners, emitter, getState, routeKey]);
}

View File

@@ -1,56 +1,76 @@
import * as React from 'react';
import type { ParamListBase, NavigationState } from '@react-navigation/routers';
import NavigationStateContext from './NavigationStateContext';
import type { NavigationState } from '@react-navigation/routers';
import NavigationBuilderContext from './NavigationBuilderContext';
import type { NavigationProp } from './types';
type Options = {
key?: string;
navigation?: NavigationProp<ParamListBase, string, NavigationState, object>;
options?: object | undefined;
};
export default function useOptionsGetters({
key,
getOptions,
getState,
}: {
key?: string;
getOptions?: () => object | undefined;
getState?: () => NavigationState;
}) {
let [
numberOfChildrenListeners,
setNumberOfChildrenListeners,
] = React.useState(0);
const optionsGettersFromChild = React.useRef<
options,
navigation,
}: Options) {
const optionsRef = React.useRef<object | undefined>(options);
const optionsGettersFromChildRef = React.useRef<
Record<string, () => object | undefined | null>
>({});
const { onOptionsChange } = React.useContext(NavigationBuilderContext);
const { addOptionsGetter: parentAddOptionsGetter } = React.useContext(
NavigationStateContext
);
const optionsChangeListener = React.useCallback(() => {
const isFocused = navigation?.isFocused() ?? true;
const hasChildren = Object.keys(optionsGettersFromChildRef.current).length;
if (isFocused && !hasChildren) {
onOptionsChange(optionsRef.current ?? {});
}
}, [navigation, onOptionsChange]);
React.useEffect(() => {
optionsRef.current = options;
optionsChangeListener();
return navigation?.addListener('focus', optionsChangeListener);
}, [navigation, options, optionsChangeListener]);
const getOptionsFromListener = React.useCallback(() => {
for (let key in optionsGettersFromChild.current) {
if (optionsGettersFromChild.current.hasOwnProperty(key)) {
const result = optionsGettersFromChild.current[key]?.();
for (let key in optionsGettersFromChildRef.current) {
if (optionsGettersFromChildRef.current.hasOwnProperty(key)) {
const result = optionsGettersFromChildRef.current[key]?.();
// null means unfocused route
if (result !== null) {
return result;
}
}
}
return null;
}, []);
const getCurrentOptions = React.useCallback(() => {
if (getState) {
const state = getState();
if (state.routes[state.index].key !== key) {
// null means unfocused route
return null;
}
const isFocused = navigation?.isFocused() ?? true;
if (!isFocused) {
return null;
}
const optionsFromListener = getOptionsFromListener();
if (optionsFromListener !== null) {
return optionsFromListener;
}
return getOptions?.() ?? undefined;
}, [getState, getOptionsFromListener, getOptions, key]);
return optionsRef.current;
}, [navigation, getOptionsFromListener]);
React.useEffect(() => {
return parentAddOptionsGetter?.(key!, getCurrentOptions);
@@ -58,26 +78,20 @@ export default function useOptionsGetters({
const addOptionsGetter = React.useCallback(
(key: string, getter: () => object | undefined | null) => {
optionsGettersFromChild.current[key] = getter;
setNumberOfChildrenListeners((prev) => prev + 1);
optionsGettersFromChildRef.current[key] = getter;
optionsChangeListener();
return () => {
setNumberOfChildrenListeners((prev) => prev - 1);
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete optionsGettersFromChild.current[key];
delete optionsGettersFromChildRef.current[key];
optionsChangeListener();
};
},
[]
);
const hasAnyChildListener = React.useMemo(
() => numberOfChildrenListeners > 0,
[numberOfChildrenListeners]
[optionsChangeListener]
);
return {
addOptionsGetter,
getCurrentOptions,
hasAnyChildListener,
};
}

View File

@@ -1,36 +0,0 @@
import * as React from 'react';
import type { NavigatorStateGetter } from './NavigationBuilderContext';
/**
* Hook which lets child navigators add getters to be called for obtaining rehydrated state.
*/
export default function useStateGetters() {
const stateGetters = React.useRef<
Record<string, NavigatorStateGetter | undefined>
>({});
const getStateForRoute = React.useCallback(
(routeKey: string) => {
const getter = stateGetters.current[routeKey];
return getter === undefined ? undefined : getter();
},
[stateGetters]
);
const addStateGetter = React.useCallback(
(key: string, getter: NavigatorStateGetter) => {
stateGetters.current[key] = getter;
return () => {
stateGetters.current[key] = undefined;
};
},
[]
);
return {
getStateForRoute,
addStateGetter,
};
}

View File

@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.1.3](https://github.com/react-navigation/react-navigation/compare/@react-navigation/devtools@5.1.2...@react-navigation/devtools@5.1.3) (2020-07-19)
**Note:** Version bump only for package @react-navigation/devtools
## [5.1.2](https://github.com/react-navigation/react-navigation/compare/@react-navigation/devtools@5.1.1...@react-navigation/devtools@5.1.2) (2020-07-10)
**Note:** Version bump only for package @react-navigation/devtools
## [5.1.1](https://github.com/react-navigation/react-navigation/compare/@react-navigation/devtools@5.1.0...@react-navigation/devtools@5.1.1) (2020-06-25)
**Note:** Version bump only for package @react-navigation/devtools
# 5.1.0 (2020-06-24)

View File

@@ -1,7 +1,7 @@
{
"name": "@react-navigation/devtools",
"description": "Developer tools for React Navigation",
"version": "5.1.0",
"version": "5.1.3",
"keywords": [
"react",
"react-native",
@@ -36,7 +36,7 @@
"clean": "del lib"
},
"dependencies": {
"@react-navigation/core": "^5.11.0",
"@react-navigation/core": "^5.12.1",
"deep-equal": "^2.0.3"
},
"devDependencies": {

View File

@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.8.6](https://github.com/react-navigation/react-navigation/compare/@react-navigation/drawer@5.8.5...@react-navigation/drawer@5.8.6) (2020-07-19)
**Note:** Version bump only for package @react-navigation/drawer
## [5.8.5](https://github.com/react-navigation/react-navigation/compare/@react-navigation/drawer@5.8.4...@react-navigation/drawer@5.8.5) (2020-07-10)
**Note:** Version bump only for package @react-navigation/drawer
## [5.8.4](https://github.com/react-navigation/react-navigation/compare/@react-navigation/drawer@5.8.3...@react-navigation/drawer@5.8.4) (2020-06-25)
**Note:** Version bump only for package @react-navigation/drawer
## [5.8.3](https://github.com/react-navigation/react-navigation/compare/@react-navigation/drawer@5.8.2...@react-navigation/drawer@5.8.3) (2020-06-24)

View File

@@ -1,7 +1,7 @@
{
"name": "@react-navigation/drawer",
"description": "Drawer navigator component with animated transitions and gesturess",
"version": "5.8.3",
"version": "5.8.6",
"keywords": [
"react-native-component",
"react-component",
@@ -46,7 +46,7 @@
},
"devDependencies": {
"@react-native-community/bob": "^0.15.1",
"@react-navigation/native": "^5.6.0",
"@react-navigation/native": "^5.7.1",
"@types/react": "^16.9.36",
"@types/react-native": "^0.62.7",
"del-cli": "^3.0.1",
@@ -56,6 +56,7 @@
"react-native-reanimated": "^1.8.0",
"react-native-safe-area-context": "^1.0.0",
"react-native-screens": "^2.7.0",
"react-native-testing-library": "^2.1.0",
"typescript": "^3.9.5"
},
"peerDependencies": {

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import { View, Text, Button } from 'react-native';
import { render, fireEvent } from 'react-native-testing-library';
import { NavigationContainer, ParamListBase } from '@react-navigation/native';
import { createDrawerNavigator, DrawerScreenProps } from '../index';
it('renders a drawer navigator with screens', async () => {
const Test = ({ route, navigation }: DrawerScreenProps<ParamListBase>) => (
<View>
<Text>Screen {route.name}</Text>
<Button onPress={() => navigation.navigate('A')} title="Go to A" />
<Button onPress={() => navigation.navigate('B')} title="Go to B" />
</View>
);
const Drawer = createDrawerNavigator();
const { findByText, queryByText } = render(
<NavigationContainer>
<Drawer.Navigator>
<Drawer.Screen name="A" component={Test} />
<Drawer.Screen name="B" component={Test} />
</Drawer.Navigator>
</NavigationContainer>
);
expect(queryByText('Screen A')).not.toBeNull();
expect(queryByText('Screen B')).toBeNull();
fireEvent(await findByText('Go to B'), 'press');
expect(queryByText('Screen B')).not.toBeNull();
});

View File

@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.2.14](https://github.com/react-navigation/react-navigation/compare/@react-navigation/material-bottom-tabs@5.2.13...@react-navigation/material-bottom-tabs@5.2.14) (2020-07-19)
**Note:** Version bump only for package @react-navigation/material-bottom-tabs
## [5.2.13](https://github.com/react-navigation/react-navigation/compare/@react-navigation/material-bottom-tabs@5.2.12...@react-navigation/material-bottom-tabs@5.2.13) (2020-07-10)
**Note:** Version bump only for package @react-navigation/material-bottom-tabs
## [5.2.12](https://github.com/react-navigation/react-navigation/compare/@react-navigation/material-bottom-tabs@5.2.11...@react-navigation/material-bottom-tabs@5.2.12) (2020-06-25)
**Note:** Version bump only for package @react-navigation/material-bottom-tabs
## [5.2.11](https://github.com/react-navigation/react-navigation/compare/@react-navigation/material-bottom-tabs@5.2.10...@react-navigation/material-bottom-tabs@5.2.11) (2020-06-24)
**Note:** Version bump only for package @react-navigation/material-bottom-tabs

View File

@@ -1,7 +1,7 @@
{
"name": "@react-navigation/material-bottom-tabs",
"description": "Integration for bottom navigation component from react-native-paper",
"version": "5.2.11",
"version": "5.2.14",
"keywords": [
"react-native-component",
"react-component",
@@ -42,7 +42,7 @@
},
"devDependencies": {
"@react-native-community/bob": "^0.15.1",
"@react-navigation/native": "^5.6.0",
"@react-navigation/native": "^5.7.1",
"@types/react": "^16.9.36",
"@types/react-native": "^0.62.7",
"@types/react-native-vector-icons": "^6.4.5",
@@ -50,6 +50,7 @@
"react": "~16.9.0",
"react-native": "~0.61.5",
"react-native-paper": "^3.10.1",
"react-native-testing-library": "^2.1.0",
"react-native-vector-icons": "^6.6.0",
"typescript": "^3.9.5"
},

View File

@@ -0,0 +1,39 @@
import * as React from 'react';
import { View, Text, Button } from 'react-native';
import { render, fireEvent } from 'react-native-testing-library';
import { NavigationContainer, ParamListBase } from '@react-navigation/native';
import {
createMaterialBottomTabNavigator,
MaterialBottomTabScreenProps,
} from '../index';
it('renders a material bottom tab navigator with screens', async () => {
const Test = ({
route,
navigation,
}: MaterialBottomTabScreenProps<ParamListBase>) => (
<View>
<Text>Screen {route.name}</Text>
<Button onPress={() => navigation.navigate('A')} title="Go to A" />
<Button onPress={() => navigation.navigate('B')} title="Go to B" />
</View>
);
const Tab = createMaterialBottomTabNavigator();
const { findByText, queryByText } = render(
<NavigationContainer>
<Tab.Navigator>
<Tab.Screen name="A" component={Test} />
<Tab.Screen name="B" component={Test} />
</Tab.Navigator>
</NavigationContainer>
);
expect(queryByText('Screen A')).not.toBeNull();
expect(queryByText('Screen B')).toBeNull();
fireEvent(await findByText('Go to B'), 'press');
expect(queryByText('Screen B')).not.toBeNull();
});

View File

@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.2.14](https://github.com/react-navigation/react-navigation/compare/@react-navigation/material-top-tabs@5.2.13...@react-navigation/material-top-tabs@5.2.14) (2020-07-19)
**Note:** Version bump only for package @react-navigation/material-top-tabs
## [5.2.13](https://github.com/react-navigation/react-navigation/compare/@react-navigation/material-top-tabs@5.2.12...@react-navigation/material-top-tabs@5.2.13) (2020-07-10)
**Note:** Version bump only for package @react-navigation/material-top-tabs
## [5.2.12](https://github.com/react-navigation/react-navigation/compare/@react-navigation/material-top-tabs@5.2.11...@react-navigation/material-top-tabs@5.2.12) (2020-06-25)
**Note:** Version bump only for package @react-navigation/material-top-tabs
## [5.2.11](https://github.com/react-navigation/react-navigation/compare/@react-navigation/material-top-tabs@5.2.10...@react-navigation/material-top-tabs@5.2.11) (2020-06-24)
**Note:** Version bump only for package @react-navigation/material-top-tabs

View File

@@ -1,7 +1,7 @@
{
"name": "@react-navigation/material-top-tabs",
"description": "Integration for the animated tab view component from react-native-tab-view",
"version": "5.2.11",
"version": "5.2.14",
"keywords": [
"react-native-component",
"react-component",
@@ -45,7 +45,7 @@
},
"devDependencies": {
"@react-native-community/bob": "^0.15.1",
"@react-navigation/native": "^5.6.0",
"@react-navigation/native": "^5.7.1",
"@types/react": "^16.9.36",
"@types/react-native": "^0.62.7",
"del-cli": "^3.0.1",
@@ -54,6 +54,7 @@
"react-native-gesture-handler": "^1.6.0",
"react-native-reanimated": "^1.8.0",
"react-native-tab-view": "^2.14.4",
"react-native-testing-library": "^2.1.0",
"typescript": "^3.9.5"
},
"peerDependencies": {

View File

@@ -0,0 +1,39 @@
import * as React from 'react';
import { View, Text, Button } from 'react-native';
import { render, fireEvent } from 'react-native-testing-library';
import { NavigationContainer, ParamListBase } from '@react-navigation/native';
import {
createMaterialTopTabNavigator,
MaterialTopTabScreenProps,
} from '../index';
it('renders a material bottom tab navigator with screens', async () => {
const Test = ({
route,
navigation,
}: MaterialTopTabScreenProps<ParamListBase>) => (
<View>
<Text>Screen {route.name}</Text>
<Button onPress={() => navigation.navigate('A')} title="Go to A" />
<Button onPress={() => navigation.navigate('B')} title="Go to B" />
</View>
);
const Tab = createMaterialTopTabNavigator();
const { findByText, queryByText } = render(
<NavigationContainer>
<Tab.Navigator>
<Tab.Screen name="A" component={Test} />
<Tab.Screen name="B" component={Test} />
</Tab.Navigator>
</NavigationContainer>
);
expect(queryByText('Screen A')).not.toBeNull();
expect(queryByText('Screen B')).toBeNull();
fireEvent(await findByText('Go to B'), 'press');
expect(queryByText('Screen B')).not.toBeNull();
});

View File

@@ -3,6 +3,40 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.7.1](https://github.com/react-navigation/react-navigation/compare/@react-navigation/native@5.7.0...@react-navigation/native@5.7.1) (2020-07-19)
**Note:** Version bump only for package @react-navigation/native
# [5.7.0](https://github.com/react-navigation/react-navigation/compare/@react-navigation/native@5.6.1...@react-navigation/native@5.7.0) (2020-07-10)
### Bug Fixes
* ensure correct document title after going back on Chrome ([8f5286e](https://github.com/react-navigation/react-navigation/commit/8f5286ef501d2e88cffbe4f7d8cdeb23a4af6cf1))
* tweak border color to match iOS default ([c665c02](https://github.com/react-navigation/react-navigation/commit/c665c027a6531cf841690940a7e2cb4ea498ba03))
### Features
* add a hook to update document title ([13c9d1e](https://github.com/react-navigation/react-navigation/commit/13c9d1e281b4626199671bce11ba62d83767564f))
* add support for badges to bottom tab bar ([96c7b68](https://github.com/react-navigation/react-navigation/commit/96c7b688ce773b3dd1f1cf7775367cd7080c94a2))
## [5.6.1](https://github.com/react-navigation/react-navigation/compare/@react-navigation/native@5.6.0...@react-navigation/native@5.6.1) (2020-06-25)
**Note:** Version bump only for package @react-navigation/native
# [5.6.0](https://github.com/react-navigation/react-navigation/compare/@react-navigation/native@5.5.1...@react-navigation/native@5.6.0) (2020-06-24)

View File

@@ -1,7 +1,7 @@
{
"name": "@react-navigation/native",
"description": "React Native integration for React Navigation",
"version": "5.6.0",
"version": "5.7.1",
"keywords": [
"react-native",
"react-navigation",
@@ -37,7 +37,7 @@
"clean": "del lib"
},
"dependencies": {
"@react-navigation/core": "^5.11.0",
"@react-navigation/core": "^5.12.1",
"nanoid": "^3.1.9"
},
"devDependencies": {

View File

@@ -9,13 +9,15 @@ import DefaultTheme from './theming/DefaultTheme';
import LinkingContext from './LinkingContext';
import useThenable from './useThenable';
import useLinking from './useLinking';
import useDocumentTitle from './useDocumentTitle';
import useBackButton from './useBackButton';
import type { Theme, LinkingOptions } from './types';
import type { Theme, LinkingOptions, DocumentTitleOptions } from './types';
type Props = NavigationContainerProps & {
theme?: Theme;
linking?: LinkingOptions;
fallback?: React.ReactNode;
documentTitle?: DocumentTitleOptions;
onReady?: () => void;
};
@@ -29,11 +31,19 @@ type Props = NavigationContainerProps & {
* @param props.theme Theme object for the navigators.
* @param props.linking Options for deep linking. Deep link handling is enabled when this prop is provided, unless `linking.enabled` is `false`.
* @param props.fallback Fallback component to render until we have finished getting initial state when linking is enabled. Defaults to `null`.
* @param props.documentTitle Options to configure the document title on Web. Updating document title is handled by default unless `documentTitle.enabled` is `false`.
* @param props.children Child elements to render the content.
* @param props.ref Ref object which refers to the navigation object containing helper methods.
*/
const NavigationContainer = React.forwardRef(function NavigationContainer(
{ theme = DefaultTheme, linking, fallback = null, onReady, ...rest }: Props,
{
theme = DefaultTheme,
linking,
fallback = null,
documentTitle,
onReady,
...rest
}: Props,
ref?: React.Ref<NavigationContainerRef | null>
) {
const isLinkingEnabled = linking ? linking.enabled !== false : false;
@@ -41,6 +51,7 @@ const NavigationContainer = React.forwardRef(function NavigationContainer(
const refContainer = React.useRef<NavigationContainerRef>(null);
useBackButton(refContainer);
useDocumentTitle(refContainer, documentTitle);
const { getInitialState } = useLinking(refContainer, {
enabled: isLinkingEnabled,

View File

@@ -62,6 +62,7 @@ const removeEventListener = (type: 'popstate', listener: () => void) => {
};
export default {
document: { title: '' },
location,
history,
addEventListener,

View File

@@ -8,6 +8,7 @@ const DarkTheme: Theme = {
card: 'rgb(18, 18, 18)',
text: 'rgb(229, 229, 231)',
border: 'rgb(39, 39, 41)',
notification: 'rgb(255, 69, 58)',
},
};

View File

@@ -7,7 +7,8 @@ const DefaultTheme: Theme = {
background: 'rgb(242, 242, 242)',
card: 'rgb(255, 255, 255)',
text: 'rgb(28, 28, 30)',
border: 'rgb(224, 224, 224)',
border: 'rgb(216, 216, 216)',
notification: 'rgb(255, 59, 48)',
},
};

View File

@@ -2,6 +2,7 @@ import type {
getStateFromPath as getStateFromPathDefault,
getPathFromState as getPathFromStateDefault,
PathConfigMap,
Route,
} from '@react-navigation/core';
export type Theme = {
@@ -12,6 +13,7 @@ export type Theme = {
card: string;
text: string;
border: string;
notification: string;
};
};
@@ -52,6 +54,14 @@ export type LinkingOptions = {
getPathFromState?: typeof getPathFromStateDefault;
};
export type DocumentTitleOptions = {
enabled?: boolean;
formatter?: (
options: Record<string, any> | undefined,
route: Route<string> | undefined
) => string;
};
export type ServerContainerRef = {
getCurrentOptions(): Record<string, any> | undefined;
};

View File

@@ -0,0 +1,3 @@
export default function useDocumentTitle() {
// Noop for React Native
}

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import type { NavigationContainerRef } from '@react-navigation/core';
import type { DocumentTitleOptions } from './types';
/**
* Set the document title for the active screen
*/
export default function useDocumentTitle(
ref: React.RefObject<NavigationContainerRef>,
{
enabled = true,
formatter = (options, route) => options?.title ?? route?.name,
}: DocumentTitleOptions = {}
) {
React.useEffect(() => {
if (!enabled) {
return;
}
const navigation = ref.current;
if (navigation) {
const title = formatter(
navigation.getCurrentOptions(),
navigation.getCurrentRoute()
);
document.title = title;
}
return navigation?.addListener('options', (e) => {
const title = formatter(e.data.options, navigation?.getCurrentRoute());
document.title = title;
});
});
}

View File

@@ -116,6 +116,19 @@ const createMemoryHistory = () => {
pending = true;
const done = () => {
// There seems to be a bug in Chrome regarding updating the title
// If we set a title just before calling `history.go`, the title gets lost
// However the value of `document.title` is still what we set it to
// It's just not displayed in the tab bar
// To update the tab bar, we need to reset the title to something else first (e.g. '')
// And set the title to what it was before so it gets applied
// It won't work without setting it to empty string coz otherwise title isn't changing
// Which means that the browser won't do anything after setting the title
const { title } = window.document;
window.document.title = '';
window.document.title = title;
pending = false;
window.removeEventListener('popstate', done);

View File

@@ -3,6 +3,18 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.4.9](https://github.com/react-navigation/react-navigation/compare/@react-navigation/routers@5.4.8...@react-navigation/routers@5.4.9) (2020-07-10)
### Bug Fixes
* mark some types as read-only ([7c3a0a0](https://github.com/react-navigation/react-navigation/commit/7c3a0a0f23629da0beb956ba5a9689ab965061ce))
* only remove non-existed routes from tab history. closes [#8567](https://github.com/react-navigation/react-navigation/issues/8567) ([374b081](https://github.com/react-navigation/react-navigation/commit/374b081b1c4b2e590259a050430eb1fcdbad3557))
## [5.4.8](https://github.com/react-navigation/react-navigation/compare/@react-navigation/routers@5.4.7...@react-navigation/routers@5.4.8) (2020-06-24)

View File

@@ -1,7 +1,7 @@
{
"name": "@react-navigation/routers",
"description": "Routers to help build custom navigators",
"version": "5.4.8",
"version": "5.4.9",
"keywords": [
"react",
"react-native",

View File

@@ -244,8 +244,9 @@ export default function TabRouter({
routeNames.indexOf(state.routes[state.index].name)
);
let history = state.history.filter((it) =>
routes.find((r) => r.key === it.key)
let history = state.history.filter(
// Type will always be 'route' for tabs, but could be different in a router extending this (e.g. drawer)
(it) => it.type !== 'route' || routes.find((r) => r.key === it.key)
);
if (!history.length) {

View File

@@ -942,3 +942,68 @@ it('changes index on focus change', () => {
expect(router.getStateForRouteFocus(state, 'qux-0')).toEqual(state);
});
it('merges params on navigate to an existing screen', () => {
const router = StackRouter({});
const options = {
routeNames: ['baz', 'bar', 'qux'],
routeParamList: {},
};
expect(
router.getStateForAction(
{
stale: false,
type: 'stack',
key: 'root',
index: 2,
routeNames: ['baz', 'bar', 'qux'],
routes: [
{ key: 'baz', name: 'baz' },
{ key: 'bar', name: 'bar', params: { answer: 42 } },
{ key: 'qux', name: 'qux' },
],
},
CommonActions.navigate('bar'),
options
)
).toEqual({
stale: false,
type: 'stack',
key: 'root',
index: 1,
routeNames: ['baz', 'bar', 'qux'],
routes: [
{ key: 'baz', name: 'baz' },
{ key: 'bar', name: 'bar', params: { answer: 42 } },
],
});
expect(
router.getStateForAction(
{
stale: false,
type: 'stack',
key: 'root',
index: 1,
routeNames: ['baz', 'bar', 'qux'],
routes: [
{ key: 'baz', name: 'baz' },
{ key: 'bar', name: 'bar', params: { answer: 42 } },
],
},
CommonActions.navigate('bar', { fruit: 'orange' }),
options
)
).toEqual({
stale: false,
type: 'stack',
key: 'root',
index: 1,
routeNames: ['baz', 'bar', 'qux'],
routes: [
{ key: 'baz', name: 'baz' },
{ key: 'bar', name: 'bar', params: { answer: 42, fruit: 'orange' } },
],
});
});

View File

@@ -1076,3 +1076,159 @@ it('updates route key history on focus change', () => {
{ type: 'route', key: 'baz-0' },
]);
});
it('merges params on navigate to an existing screen', () => {
const router = TabRouter({});
const options = {
routeNames: ['baz', 'bar', 'qux'],
routeParamList: {},
};
expect(
router.getStateForAction(
{
stale: false,
type: 'tab',
key: 'root',
index: 1,
routeNames: ['baz', 'bar', 'qux'],
routes: [
{ key: 'baz', name: 'baz' },
{ key: 'bar', name: 'bar', params: { answer: 42 } },
{ key: 'qux', name: 'qux' },
],
history: [{ type: 'route', key: 'baz' }],
},
CommonActions.navigate('bar'),
options
)
).toEqual({
stale: false,
type: 'tab',
key: 'root',
index: 1,
routeNames: ['baz', 'bar', 'qux'],
routes: [
{ key: 'baz', name: 'baz' },
{ key: 'bar', name: 'bar', params: { answer: 42 } },
{ key: 'qux', name: 'qux' },
],
history: [
{ type: 'route', key: 'baz' },
{ type: 'route', key: 'bar' },
],
});
expect(
router.getStateForAction(
{
stale: false,
type: 'tab',
key: 'root',
index: 1,
routeNames: ['baz', 'bar', 'qux'],
routes: [
{ key: 'baz', name: 'baz' },
{ key: 'bar', name: 'bar', params: { answer: 42 } },
{ key: 'qux', name: 'qux' },
],
history: [{ type: 'route', key: 'baz' }],
},
CommonActions.navigate('bar', { fruit: 'orange' }),
options
)
).toEqual({
stale: false,
type: 'tab',
key: 'root',
index: 1,
routeNames: ['baz', 'bar', 'qux'],
routes: [
{ key: 'baz', name: 'baz' },
{ key: 'bar', name: 'bar', params: { answer: 42, fruit: 'orange' } },
{ key: 'qux', name: 'qux' },
],
history: [
{ type: 'route', key: 'baz' },
{ type: 'route', key: 'bar' },
],
});
});
it('merges params on jump to an existing screen', () => {
const router = TabRouter({});
const options = {
routeNames: ['baz', 'bar', 'qux'],
routeParamList: {},
};
expect(
router.getStateForAction(
{
stale: false,
type: 'tab',
key: 'root',
index: 1,
routeNames: ['baz', 'bar', 'qux'],
routes: [
{ key: 'baz', name: 'baz' },
{ key: 'bar', name: 'bar', params: { answer: 42 } },
{ key: 'qux', name: 'qux' },
],
history: [{ type: 'route', key: 'baz' }],
},
TabActions.jumpTo('bar'),
options
)
).toEqual({
stale: false,
type: 'tab',
key: 'root',
index: 1,
routeNames: ['baz', 'bar', 'qux'],
routes: [
{ key: 'baz', name: 'baz' },
{ key: 'bar', name: 'bar', params: { answer: 42 } },
{ key: 'qux', name: 'qux' },
],
history: [
{ type: 'route', key: 'baz' },
{ type: 'route', key: 'bar' },
],
});
expect(
router.getStateForAction(
{
stale: false,
type: 'tab',
key: 'root',
index: 1,
routeNames: ['baz', 'bar', 'qux'],
routes: [
{ key: 'baz', name: 'baz' },
{ key: 'bar', name: 'bar', params: { answer: 42 } },
{ key: 'qux', name: 'qux' },
],
history: [{ type: 'route', key: 'baz' }],
},
TabActions.jumpTo('bar', { fruit: 'orange' }),
options
)
).toEqual({
stale: false,
type: 'tab',
key: 'root',
index: 1,
routeNames: ['baz', 'bar', 'qux'],
routes: [
{ key: 'baz', name: 'baz' },
{ key: 'bar', name: 'bar', params: { answer: 42, fruit: 'orange' } },
{ key: 'qux', name: 'qux' },
],
history: [
{ type: 'route', key: 'baz' },
{ type: 'route', key: 'bar' },
],
});
});

View File

@@ -2,7 +2,7 @@ import type * as CommonActions from './CommonActions';
export type CommonNavigationAction = CommonActions.Action;
export type NavigationState = {
export type NavigationState = Readonly<{
/**
* Unique key for the navigation state.
*/
@@ -35,26 +35,27 @@ export type NavigationState = {
* Whether the navigation state has been rehydrated.
*/
stale: false;
};
}>;
export type InitialState = Partial<
Omit<NavigationState, 'stale' | 'routes'>
> & {
routes: (Omit<Route<string>, 'key'> & { state?: InitialState })[];
};
export type InitialState = Readonly<
Partial<Omit<NavigationState, 'stale' | 'routes'>> & {
routes: (Omit<Route<string>, 'key'> & { state?: InitialState })[];
}
>;
export type PartialState<State extends NavigationState> = Partial<
Omit<State, 'stale' | 'type' | 'key' | 'routes' | 'routeNames'>
> & {
stale?: true;
type?: string;
routes: (Omit<Route<string>, 'key'> & {
key?: string;
state?: InitialState;
})[];
};
> &
Readonly<{
stale?: true;
type?: string;
routes: (Omit<Route<string>, 'key'> & {
key?: string;
state?: InitialState;
})[];
}>;
export type Route<RouteName extends string> = {
export type Route<RouteName extends string> = Readonly<{
/**
* Unique key for the route.
*/
@@ -67,11 +68,11 @@ export type Route<RouteName extends string> = {
* Params for the route.
*/
params?: object;
};
}>;
export type ParamListBase = Record<string, object | undefined>;
export type NavigationAction = {
export type NavigationAction = Readonly<{
/**
* Type of the action (e.g. `NAVIGATE`)
*/
@@ -88,7 +89,7 @@ export type NavigationAction = {
* Key of the navigator which should handle this action.
*/
target?: string;
};
}>;
export type ActionCreators<Action extends NavigationAction> = {
[key: string]: (...args: any) => Action;

View File

@@ -3,6 +3,33 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [5.7.1](https://github.com/react-navigation/react-navigation/compare/@react-navigation/stack@5.7.0...@react-navigation/stack@5.7.1) (2020-07-19)
**Note:** Version bump only for package @react-navigation/stack
# [5.7.0](https://github.com/react-navigation/react-navigation/compare/@react-navigation/stack@5.6.2...@react-navigation/stack@5.7.0) (2020-07-10)
### Features
* add a `beforeRemove` event ([6925e92](https://github.com/react-navigation/react-navigation/commit/6925e92dc3e9885e3f552ca5e5eb51ae1521e54e))
## [5.6.2](https://github.com/react-navigation/react-navigation/compare/@react-navigation/stack@5.6.1...@react-navigation/stack@5.6.2) (2020-06-25)
**Note:** Version bump only for package @react-navigation/stack
## [5.6.1](https://github.com/react-navigation/react-navigation/compare/@react-navigation/stack@5.6.0...@react-navigation/stack@5.6.1) (2020-06-25)

View File

@@ -1,7 +1,7 @@
{
"name": "@react-navigation/stack",
"description": "Stack navigator component for iOS and Android with animated transitions and gestures",
"version": "5.6.1",
"version": "5.7.1",
"keywords": [
"react-native-component",
"react-component",
@@ -46,7 +46,7 @@
"devDependencies": {
"@react-native-community/bob": "^0.15.1",
"@react-native-community/masked-view": "^0.1.10",
"@react-navigation/native": "^5.6.0",
"@react-navigation/native": "^5.7.1",
"@types/color": "^3.0.1",
"@types/react": "^16.9.36",
"@types/react-native": "^0.62.7",
@@ -56,6 +56,7 @@
"react-native-gesture-handler": "^1.6.0",
"react-native-safe-area-context": "^1.0.0",
"react-native-screens": "^2.7.0",
"react-native-testing-library": "^2.1.0",
"typescript": "^3.9.5"
},
"peerDependencies": {

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import { View, Text, Button } from 'react-native';
import { render, fireEvent } from 'react-native-testing-library';
import { NavigationContainer, ParamListBase } from '@react-navigation/native';
import { createStackNavigator, StackScreenProps } from '../index';
it('renders a stack navigator with screens', async () => {
const Test = ({ route, navigation }: StackScreenProps<ParamListBase>) => (
<View>
<Text>Screen {route.name}</Text>
<Button onPress={() => navigation.navigate('A')} title="Go to A" />
<Button onPress={() => navigation.navigate('B')} title="Go to B" />
</View>
);
const Stack = createStackNavigator();
const { findByText, queryByText } = render(
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="A" component={Test} />
<Stack.Screen name="B" component={Test} />
</Stack.Navigator>
</NavigationContainer>
);
expect(queryByText('Screen A')).not.toBeNull();
expect(queryByText('Screen B')).toBeNull();
fireEvent.press(await findByText('Go to B'));
expect(queryByText('Screen B')).not.toBeNull();
});

View File

@@ -97,6 +97,7 @@ export default class Card extends React.Component<Props> {
componentDidMount() {
this.animate({ closing: this.props.closing });
this.isCurrentlyMounted = true;
}
componentDidUpdate(prevProps: Props) {
@@ -115,8 +116,11 @@ export default class Card extends React.Component<Props> {
this.inverted.setValue(getInvertedMultiplier(gestureDirection));
}
const toValue = this.getAnimateToValue(this.props);
if (
this.getAnimateToValue(this.props) !== this.getAnimateToValue(prevProps)
this.getAnimateToValue(prevProps) !== toValue ||
this.lastToValue !== toValue
) {
// We need to trigger the animation when route was closed
// Thr route might have been closed by a `POP` action or by a gesture
@@ -128,9 +132,12 @@ export default class Card extends React.Component<Props> {
}
componentWillUnmount() {
this.isCurrentlyMounted = false;
this.handleEndInteraction();
}
private isCurrentlyMounted = false;
private isClosing = new Animated.Value(FALSE);
private inverted = new Animated.Value(
@@ -148,6 +155,8 @@ export default class Card extends React.Component<Props> {
private pendingGestureCallback: number | undefined;
private lastToValue: number | undefined;
private animate = ({
closing,
velocity,
@@ -168,6 +177,8 @@ export default class Card extends React.Component<Props> {
closing,
});
this.lastToValue = toValue;
const spec = closing ? transitionSpec.close : transitionSpec.open;
const animation =
@@ -196,6 +207,11 @@ export default class Card extends React.Component<Props> {
} else {
onOpen();
}
if (this.isCurrentlyMounted) {
// Make sure to re-open screen if it wasn't removed
this.forceUpdate();
}
}
});
};
@@ -301,10 +317,13 @@ export default class Card extends React.Component<Props> {
if (closing) {
// We call onClose with a delay to make sure that the animation has already started
// This will make sure that the state update caused by this doesn't affect start of animation
this.pendingGestureCallback = (setTimeout(
onClose,
32
) as any) as number;
this.pendingGestureCallback = (setTimeout(() => {
onClose();
// Trigger an update after we dispatch the action to remove the screen
// This will make sure that we check if the screen didn't get removed so we can cancel the animation
this.forceUpdate();
}, 32) as any) as number;
}
onGestureEnd?.();

View File

@@ -422,6 +422,11 @@
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.1.tgz#ec5a5cf0eec925b66c60580328b122c01230a127"
integrity sha512-fvoGeXt0bJc7VMWZGCAEBEMo/HAjW2mP8apF5eXK0wSqwLAVHAISCWRoLMBMUs2kqeaG77jltVqu4Hn8Egl3nA==
"@babel/helper-plugin-utils@^7.10.4":
version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375"
integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==
"@babel/helper-regex@^7.10.1":
version "7.10.1"
resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.10.1.tgz#021cf1a7ba99822f993222a001cc3fec83255b96"
@@ -897,6 +902,13 @@
dependencies:
"@babel/helper-plugin-utils" "^7.10.1"
"@babel/plugin-syntax-jsx@^7.10.4":
version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.10.4.tgz#39abaae3cbf710c4373d8429484e6ba21340166c"
integrity sha512-KCg9mio9jwiARCB7WAcQ7Y1q+qicILjoK8LP/VkPkEKaf5dkaZZK1EcTe91a3JJlZ3qy6L5s9X52boEYi8DM9g==
dependencies:
"@babel/helper-plugin-utils" "^7.10.4"
"@babel/plugin-syntax-logical-assignment-operators@^7.8.3":
version "7.8.3"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.8.3.tgz#3995d7d7ffff432f6ddc742b47e730c054599897"
@@ -904,7 +916,7 @@
dependencies:
"@babel/helper-plugin-utils" "^7.8.3"
"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0", "@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3":
"@babel/plugin-syntax-nullish-coalescing-operator@^7.0.0", "@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0", "@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3":
version "7.8.3"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9"
integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==
@@ -939,7 +951,7 @@
dependencies:
"@babel/helper-plugin-utils" "^7.8.0"
"@babel/plugin-syntax-optional-chaining@^7.8.0", "@babel/plugin-syntax-optional-chaining@^7.8.3":
"@babel/plugin-syntax-optional-chaining@^7.0.0", "@babel/plugin-syntax-optional-chaining@^7.8.0", "@babel/plugin-syntax-optional-chaining@^7.8.3":
version "7.8.3"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a"
integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==
@@ -1383,6 +1395,14 @@
"@babel/helper-plugin-utils" "^7.10.1"
"@babel/plugin-syntax-jsx" "^7.10.1"
"@babel/plugin-transform-react-jsx-self@^7.0.0":
version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.10.4.tgz#cd301a5fed8988c182ed0b9d55e9bd6db0bd9369"
integrity sha512-yOvxY2pDiVJi0axdTWHSMi5T0DILN+H+SaeJeACHKjQLezEzhLx9nEF9xgpBLPtkZsks9cnb5P9iBEi21En3gg==
dependencies:
"@babel/helper-plugin-utils" "^7.10.4"
"@babel/plugin-syntax-jsx" "^7.10.4"
"@babel/plugin-transform-react-jsx-self@^7.10.1":
version "7.10.1"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.10.1.tgz#22143e14388d72eb88649606bb9e46f421bc3821"
@@ -1825,13 +1845,6 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.10.2":
version "7.10.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.2.tgz#d103f21f2602497d38348a32e008637d506db839"
integrity sha512-6sF3uQw2ivImfVIl62RZ7MXhO2tap69WeWK57vAaimT6AZbE4FbqjdEJIN1UqoD6wI6B+1n9UiagafH1sxjOtg==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.0.0", "@babel/template@^7.3.3", "@babel/template@^7.7.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6":
version "7.8.6"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b"
@@ -5854,7 +5867,7 @@ babel-jest@^26.0.1:
graceful-fs "^4.2.4"
slash "^3.0.0"
babel-loader@8.1.0:
babel-loader@8.1.0, babel-loader@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.1.0.tgz#c611d5112bd5209abe8b9fa84c3e4da25275f1c3"
integrity sha512-7q7nC1tYOrqvUrN3LQK4GwSk/TQorZSOlO9C+RZDZpODgyN4ZlCqE5q9cDsyWOliN+aU9B4JX01xK9eJXowJLw==
@@ -7575,7 +7588,7 @@ core-js@^2.2.2, core-js@^2.4.0, core-js@^2.4.1, core-js@^2.5.0, core-js@^2.6.5:
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c"
integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==
core-js@^3.2.1, core-js@^3.6.5:
core-js@^3.2.1:
version "3.6.5"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a"
integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==
@@ -13745,6 +13758,50 @@ metro-react-native-babel-preset@^0.56.0, metro-react-native-babel-preset@^0.56.4
"@babel/template" "^7.0.0"
react-refresh "^0.4.0"
metro-react-native-babel-preset@^0.59.0:
version "0.59.0"
resolved "https://registry.yarnpkg.com/metro-react-native-babel-preset/-/metro-react-native-babel-preset-0.59.0.tgz#20e020bc6ac9849e1477de1333d303ed42aba225"
integrity sha512-BoO6ncPfceIDReIH8pQ5tQptcGo5yRWQXJGVXfANbiKLq4tfgdZB1C1e2rMUJ6iypmeJU9dzl+EhPmIFKtgREg==
dependencies:
"@babel/plugin-proposal-class-properties" "^7.0.0"
"@babel/plugin-proposal-export-default-from" "^7.0.0"
"@babel/plugin-proposal-nullish-coalescing-operator" "^7.0.0"
"@babel/plugin-proposal-object-rest-spread" "^7.0.0"
"@babel/plugin-proposal-optional-catch-binding" "^7.0.0"
"@babel/plugin-proposal-optional-chaining" "^7.0.0"
"@babel/plugin-syntax-dynamic-import" "^7.0.0"
"@babel/plugin-syntax-export-default-from" "^7.0.0"
"@babel/plugin-syntax-flow" "^7.2.0"
"@babel/plugin-syntax-nullish-coalescing-operator" "^7.0.0"
"@babel/plugin-syntax-optional-chaining" "^7.0.0"
"@babel/plugin-transform-arrow-functions" "^7.0.0"
"@babel/plugin-transform-block-scoping" "^7.0.0"
"@babel/plugin-transform-classes" "^7.0.0"
"@babel/plugin-transform-computed-properties" "^7.0.0"
"@babel/plugin-transform-destructuring" "^7.0.0"
"@babel/plugin-transform-exponentiation-operator" "^7.0.0"
"@babel/plugin-transform-flow-strip-types" "^7.0.0"
"@babel/plugin-transform-for-of" "^7.0.0"
"@babel/plugin-transform-function-name" "^7.0.0"
"@babel/plugin-transform-literals" "^7.0.0"
"@babel/plugin-transform-modules-commonjs" "^7.0.0"
"@babel/plugin-transform-object-assign" "^7.0.0"
"@babel/plugin-transform-parameters" "^7.0.0"
"@babel/plugin-transform-react-display-name" "^7.0.0"
"@babel/plugin-transform-react-jsx" "^7.0.0"
"@babel/plugin-transform-react-jsx-self" "^7.0.0"
"@babel/plugin-transform-react-jsx-source" "^7.0.0"
"@babel/plugin-transform-regenerator" "^7.0.0"
"@babel/plugin-transform-runtime" "^7.0.0"
"@babel/plugin-transform-shorthand-properties" "^7.0.0"
"@babel/plugin-transform-spread" "^7.0.0"
"@babel/plugin-transform-sticky-regex" "^7.0.0"
"@babel/plugin-transform-template-literals" "^7.0.0"
"@babel/plugin-transform-typescript" "^7.5.0"
"@babel/plugin-transform-unicode-regex" "^7.0.0"
"@babel/template" "^7.0.0"
react-refresh "^0.4.0"
metro-react-native-babel-transformer@^0.56.0:
version "0.56.4"
resolved "https://registry.yarnpkg.com/metro-react-native-babel-transformer/-/metro-react-native-babel-transformer-0.56.4.tgz#3c6e48b605c305362ee624e45ff338656e35fc1d"