mirror of
https://github.com/zhigang1992/react-navigation.git
synced 2026-01-15 22:45:41 +08:00
Compare commits
12 Commits
@react-nav
...
@react-nav
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21f61d6eeb | ||
|
|
8774ca97e1 | ||
|
|
e653d55479 | ||
|
|
78afbffe97 | ||
|
|
762cc44578 | ||
|
|
c3bd349d77 | ||
|
|
5dcaf903f3 | ||
|
|
2d66ef93ec | ||
|
|
4fe72e3ce7 | ||
|
|
ab1f79c096 | ||
|
|
9305bfa939 | ||
|
|
0c3c450f5f |
6
.github/workflows/expo-preview.yml
vendored
6
.github/workflows/expo-preview.yml
vendored
@@ -27,10 +27,8 @@ jobs:
|
|||||||
id: yarn-cache
|
id: yarn-cache
|
||||||
uses: actions/cache@master
|
uses: actions/cache@master
|
||||||
with:
|
with:
|
||||||
path: |
|
path: '**/node_modules'
|
||||||
node_modules
|
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||||
*/*/node_modules
|
|
||||||
key: ${{ runner.os }}-yarn-v1-${{ hashFiles('**/yarn.lock') }}
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.yarn-cache.outputs.cache-hit != 'true'
|
if: steps.yarn-cache.outputs.cache-hit != 'true'
|
||||||
|
|||||||
6
.github/workflows/expo.yml
vendored
6
.github/workflows/expo.yml
vendored
@@ -29,10 +29,8 @@ jobs:
|
|||||||
id: yarn-cache
|
id: yarn-cache
|
||||||
uses: actions/cache@master
|
uses: actions/cache@master
|
||||||
with:
|
with:
|
||||||
path: |
|
path: '**/node_modules'
|
||||||
node_modules
|
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||||
*/*/node_modules
|
|
||||||
key: ${{ runner.os }}-yarn-v1-${{ hashFiles('**/yarn.lock') }}
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.yarn-cache.outputs.cache-hit != 'true'
|
if: steps.yarn-cache.outputs.cache-hit != 'true'
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ app.use(async (ctx) => {
|
|||||||
>
|
>
|
||||||
${css}
|
${css}
|
||||||
<title>${ref.current?.getCurrentOptions()?.title}</title>
|
<title>${ref.current?.getCurrentOptions()?.title}</title>
|
||||||
<body style="height: 100%">
|
<body style="min-height: 100%">
|
||||||
<div id="root" style="display: flex; height: 100%">
|
<div id="root" style="display: flex; min-height: 100vh">
|
||||||
${html}
|
${html}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
40
example/src/Screens/NotFound.tsx
Normal file
40
example/src/Screens/NotFound.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { Button } from 'react-native-paper';
|
||||||
|
|
||||||
|
const NotFoundScreen = ({
|
||||||
|
navigation,
|
||||||
|
}: {
|
||||||
|
navigation: StackNavigationProp<{ Home: undefined }>;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>404 Not Found</Text>
|
||||||
|
<Button
|
||||||
|
mode="contained"
|
||||||
|
onPress={() => navigation.navigate('Home')}
|
||||||
|
style={styles.button}
|
||||||
|
>
|
||||||
|
Go to home
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotFoundScreen;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
title: {
|
||||||
|
fontSize: 36,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
margin: 24,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { View, StyleSheet, ScrollView, Alert, Platform } from 'react-native';
|
import {
|
||||||
|
Animated,
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
} from 'react-native';
|
||||||
import { Button, Appbar } from 'react-native-paper';
|
import { Button, Appbar } from 'react-native-paper';
|
||||||
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
import { RouteProp, ParamListBase } from '@react-navigation/native';
|
import { RouteProp, ParamListBase } from '@react-navigation/native';
|
||||||
@@ -8,6 +15,8 @@ import {
|
|||||||
StackNavigationProp,
|
StackNavigationProp,
|
||||||
HeaderBackground,
|
HeaderBackground,
|
||||||
useHeaderHeight,
|
useHeaderHeight,
|
||||||
|
Header,
|
||||||
|
StackHeaderProps,
|
||||||
} from '@react-navigation/stack';
|
} from '@react-navigation/stack';
|
||||||
import BlurView from '../Shared/BlurView';
|
import BlurView from '../Shared/BlurView';
|
||||||
import Article from '../Shared/Article';
|
import Article from '../Shared/Article';
|
||||||
@@ -91,6 +100,25 @@ type Props = Partial<React.ComponentProps<typeof SimpleStack.Navigator>> & {
|
|||||||
navigation: StackNavigationProp<ParamListBase>;
|
navigation: StackNavigationProp<ParamListBase>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function CustomHeader(props: StackHeaderProps) {
|
||||||
|
const { current, next } = props.scene.progress;
|
||||||
|
|
||||||
|
const progress = Animated.add(current, next || 0);
|
||||||
|
const opacity = progress.interpolate({
|
||||||
|
inputRange: [0, 1, 2],
|
||||||
|
outputRange: [0, 1, 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header {...props} />
|
||||||
|
<Animated.Text style={[styles.banner, { opacity }]}>
|
||||||
|
Why hello there, pardner!
|
||||||
|
</Animated.Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SimpleStackScreen({ navigation, ...rest }: Props) {
|
export default function SimpleStackScreen({ navigation, ...rest }: Props) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
@@ -103,6 +131,7 @@ export default function SimpleStackScreen({ navigation, ...rest }: Props) {
|
|||||||
component={ArticleScreen}
|
component={ArticleScreen}
|
||||||
options={({ route }) => ({
|
options={({ route }) => ({
|
||||||
title: `Article by ${route.params?.author}`,
|
title: `Article by ${route.params?.author}`,
|
||||||
|
header: CustomHeader,
|
||||||
headerTintColor: '#fff',
|
headerTintColor: '#fff',
|
||||||
headerStyle: { backgroundColor: '#ff005d' },
|
headerStyle: { backgroundColor: '#ff005d' },
|
||||||
headerBackTitleVisible: false,
|
headerBackTitleVisible: false,
|
||||||
@@ -160,4 +189,10 @@ const styles = StyleSheet.create({
|
|||||||
button: {
|
button: {
|
||||||
margin: 8,
|
margin: 8,
|
||||||
},
|
},
|
||||||
|
banner: {
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'tomato',
|
||||||
|
backgroundColor: 'papayawhip',
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
NavigationContainer,
|
NavigationContainer,
|
||||||
DefaultTheme,
|
DefaultTheme,
|
||||||
DarkTheme,
|
DarkTheme,
|
||||||
|
PathConfig,
|
||||||
} from '@react-navigation/native';
|
} from '@react-navigation/native';
|
||||||
import {
|
import {
|
||||||
createDrawerNavigator,
|
createDrawerNavigator,
|
||||||
@@ -48,6 +49,7 @@ import StackHeaderCustomization from './Screens/StackHeaderCustomization';
|
|||||||
import BottomTabs from './Screens/BottomTabs';
|
import BottomTabs from './Screens/BottomTabs';
|
||||||
import MaterialTopTabsScreen from './Screens/MaterialTopTabs';
|
import MaterialTopTabsScreen from './Screens/MaterialTopTabs';
|
||||||
import MaterialBottomTabs from './Screens/MaterialBottomTabs';
|
import MaterialBottomTabs from './Screens/MaterialBottomTabs';
|
||||||
|
import NotFound from './Screens/NotFound';
|
||||||
import DynamicTabs from './Screens/DynamicTabs';
|
import DynamicTabs from './Screens/DynamicTabs';
|
||||||
import AuthFlow from './Screens/AuthFlow';
|
import AuthFlow from './Screens/AuthFlow';
|
||||||
import CompatAPI from './Screens/CompatAPI';
|
import CompatAPI from './Screens/CompatAPI';
|
||||||
@@ -68,6 +70,7 @@ type RootDrawerParamList = {
|
|||||||
|
|
||||||
type RootStackParamList = {
|
type RootStackParamList = {
|
||||||
Home: undefined;
|
Home: undefined;
|
||||||
|
NotFound: undefined;
|
||||||
} & {
|
} & {
|
||||||
[P in keyof typeof SCREENS]: undefined;
|
[P in keyof typeof SCREENS]: undefined;
|
||||||
};
|
};
|
||||||
@@ -221,19 +224,17 @@ export default function App() {
|
|||||||
Root: {
|
Root: {
|
||||||
path: '',
|
path: '',
|
||||||
initialRouteName: 'Home',
|
initialRouteName: 'Home',
|
||||||
screens: Object.keys(SCREENS).reduce<{ [key: string]: string }>(
|
screens: Object.keys(SCREENS).reduce<PathConfig>(
|
||||||
(acc, name) => {
|
(acc, name) => {
|
||||||
// Convert screen names such as SimpleStack to kebab case (simple-stack)
|
// Convert screen names such as SimpleStack to kebab case (simple-stack)
|
||||||
acc[name] = name
|
const path = name
|
||||||
.replace(/([A-Z]+)/g, '-$1')
|
.replace(/([A-Z]+)/g, '-$1')
|
||||||
.replace(/^-/, '')
|
.replace(/^-/, '')
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|
||||||
return acc;
|
acc[name] = {
|
||||||
},
|
path,
|
||||||
{ Home: '' }
|
screens: {
|
||||||
),
|
|
||||||
},
|
|
||||||
Article: {
|
Article: {
|
||||||
path: 'article/:author?',
|
path: 'article/:author?',
|
||||||
parse: {
|
parse: {
|
||||||
@@ -250,6 +251,18 @@ export default function App() {
|
|||||||
Chat: 'chat',
|
Chat: 'chat',
|
||||||
Contacts: 'people',
|
Contacts: 'people',
|
||||||
NewsFeed: 'feed',
|
NewsFeed: 'feed',
|
||||||
|
Dialog: 'dialog',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Home: '',
|
||||||
|
NotFound: '*',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
fallback={<Text>Loading…</Text>}
|
fallback={<Text>Loading…</Text>}
|
||||||
@@ -332,6 +345,11 @@ export default function App() {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
</Stack.Screen>
|
</Stack.Screen>
|
||||||
|
<Stack.Screen
|
||||||
|
name="NotFound"
|
||||||
|
component={NotFound}
|
||||||
|
options={{ title: 'Oops!' }}
|
||||||
|
/>
|
||||||
{(Object.keys(SCREENS) as (keyof typeof SCREENS)[]).map(
|
{(Object.keys(SCREENS) as (keyof typeof SCREENS)[]).map(
|
||||||
(name) => (
|
(name) => (
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
## [5.5.2](https://github.com/react-navigation/react-navigation/tree/master/packages/bottom-tabs/compare/@react-navigation/bottom-tabs@5.5.1...@react-navigation/bottom-tabs@5.5.2) (2020-06-06)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @react-navigation/bottom-tabs
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [5.5.1](https://github.com/react-navigation/react-navigation/tree/master/packages/bottom-tabs/compare/@react-navigation/bottom-tabs@5.5.0...@react-navigation/bottom-tabs@5.5.1) (2020-05-27)
|
## [5.5.1](https://github.com/react-navigation/react-navigation/tree/master/packages/bottom-tabs/compare/@react-navigation/bottom-tabs@5.5.0...@react-navigation/bottom-tabs@5.5.1) (2020-05-27)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@react-navigation/bottom-tabs",
|
"name": "@react-navigation/bottom-tabs",
|
||||||
"description": "Bottom tab navigator following iOS design guidelines",
|
"description": "Bottom tab navigator following iOS design guidelines",
|
||||||
"version": "5.5.1",
|
"version": "5.5.2",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"react-native-component",
|
"react-native-component",
|
||||||
"react-component",
|
"react-component",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-native-community/bob": "^0.14.3",
|
"@react-native-community/bob": "^0.14.3",
|
||||||
"@react-navigation/native": "^5.5.0",
|
"@react-navigation/native": "^5.5.1",
|
||||||
"@types/color": "^3.0.1",
|
"@types/color": "^3.0.1",
|
||||||
"@types/react": "^16.9.34",
|
"@types/react": "^16.9.34",
|
||||||
"@types/react-native": "^0.62.7",
|
"@types/react-native": "^0.62.7",
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
## [5.1.26](https://github.com/react-navigation/react-navigation/tree/master/packages/compat/compare/@react-navigation/compat@5.1.25...@react-navigation/compat@5.1.26) (2020-06-06)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @react-navigation/compat
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [5.1.25](https://github.com/react-navigation/react-navigation/tree/master/packages/compat/compare/@react-navigation/compat@5.1.24...@react-navigation/compat@5.1.25) (2020-05-27)
|
## [5.1.25](https://github.com/react-navigation/react-navigation/tree/master/packages/compat/compare/@react-navigation/compat@5.1.24...@react-navigation/compat@5.1.25) (2020-05-27)
|
||||||
|
|
||||||
**Note:** Version bump only for package @react-navigation/compat
|
**Note:** Version bump only for package @react-navigation/compat
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@react-navigation/compat",
|
"name": "@react-navigation/compat",
|
||||||
"description": "Compatibility layer to write navigator definitions in static configuration format",
|
"description": "Compatibility layer to write navigator definitions in static configuration format",
|
||||||
"version": "5.1.25",
|
"version": "5.1.26",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": "https://github.com/react-navigation/react-navigation/tree/master/packages/compat",
|
"repository": "https://github.com/react-navigation/react-navigation/tree/master/packages/compat",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-native-community/bob": "^0.14.3",
|
"@react-native-community/bob": "^0.14.3",
|
||||||
"@react-navigation/native": "^5.5.0",
|
"@react-navigation/native": "^5.5.1",
|
||||||
"@types/react": "^16.9.34",
|
"@types/react": "^16.9.34",
|
||||||
"react": "~16.9.0",
|
"react": "~16.9.0",
|
||||||
"typescript": "^3.8.3"
|
"typescript": "^3.8.3"
|
||||||
|
|||||||
@@ -3,6 +3,25 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
# [5.10.0](https://github.com/react-navigation/react-navigation/tree/master/packages/core/compare/@react-navigation/core@5.9.0...@react-navigation/core@5.10.0) (2020-06-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* catch missing params when they are required in navigate ([#8389](https://github.com/react-navigation/react-navigation/tree/master/packages/core/issues/8389)) ([8774ca9](https://github.com/react-navigation/react-navigation/tree/master/packages/core/commit/8774ca97e1da91e97677ecd816c85f66af296b93))
|
||||||
|
* make sure the wildcard pattern catches nested unmatched routes ([c3bd349](https://github.com/react-navigation/react-navigation/tree/master/packages/core/commit/c3bd349d77688011c9c55027edd66c6f39de2ade))
|
||||||
|
* only use the query params for focused route in path ([2d66ef9](https://github.com/react-navigation/react-navigation/tree/master/packages/core/commit/2d66ef93ec9923a452415c482c40e7c6b769917c))
|
||||||
|
* prevent state change being emitted unnecessarily ([ab1f79c](https://github.com/react-navigation/react-navigation/tree/master/packages/core/commit/ab1f79c096e94475a4da1acf1c850d04fb1bc4cf))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add wildcard patterns for paths ([4fe72e3](https://github.com/react-navigation/react-navigation/tree/master/packages/core/commit/4fe72e3ce7bae9120d04e490401f3bad58ebdf5c)), closes [#8019](https://github.com/react-navigation/react-navigation/tree/master/packages/core/issues/8019)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# [5.9.0](https://github.com/react-navigation/react-navigation/tree/master/packages/core/compare/@react-navigation/core@5.8.2...@react-navigation/core@5.9.0) (2020-05-27)
|
# [5.9.0](https://github.com/react-navigation/react-navigation/tree/master/packages/core/compare/@react-navigation/core@5.8.2...@react-navigation/core@5.9.0) (2020-05-27)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@react-navigation/core",
|
"name": "@react-navigation/core",
|
||||||
"description": "Core utilities for building navigators",
|
"description": "Core utilities for building navigators",
|
||||||
"version": "5.9.0",
|
"version": "5.10.0",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"react",
|
"react",
|
||||||
"react-native",
|
"react-native",
|
||||||
|
|||||||
@@ -237,6 +237,12 @@ const BaseNavigationContainer = React.forwardRef(
|
|||||||
[getKey, getState, setKey, setState, state, addOptionsGetter]
|
[getKey, getState, setKey, setState, state, addOptionsGetter]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onStateChangeRef = React.useRef(onStateChange);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
onStateChangeRef.current = onStateChange;
|
||||||
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
if (
|
if (
|
||||||
@@ -263,12 +269,12 @@ const BaseNavigationContainer = React.forwardRef(
|
|||||||
trackState(getRootState);
|
trackState(getRootState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isFirstMountRef.current && onStateChange) {
|
if (!isFirstMountRef.current && onStateChangeRef.current) {
|
||||||
onStateChange(getRootState());
|
onStateChangeRef.current(getRootState());
|
||||||
}
|
}
|
||||||
|
|
||||||
isFirstMountRef.current = false;
|
isFirstMountRef.current = false;
|
||||||
}, [onStateChange, trackState, getRootState, emitter, state]);
|
}, [trackState, getRootState, emitter, state]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScheduleUpdateContext.Provider value={scheduleContext}>
|
<ScheduleUpdateContext.Provider value={scheduleContext}>
|
||||||
|
|||||||
@@ -1265,3 +1265,175 @@ it('replaces undefined query params', () => {
|
|||||||
expect(getPathFromState(state, config)).toBe(path);
|
expect(getPathFromState(state, config)).toBe(path);
|
||||||
expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path);
|
expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('matches wildcard patterns at root', () => {
|
||||||
|
const path = '/test/bar/42/whatever';
|
||||||
|
const config = {
|
||||||
|
404: '*',
|
||||||
|
Foo: {
|
||||||
|
screens: {
|
||||||
|
Bar: {
|
||||||
|
path: '/bar/:id/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
routes: [{ name: '404' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getPathFromState(state, config)).toBe('/404');
|
||||||
|
expect(getPathFromState(getStateFromPath(path, config), config)).toBe('/404');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches wildcard patterns at nested level', () => {
|
||||||
|
const path = '/bar/42/whatever/baz/initt';
|
||||||
|
const config = {
|
||||||
|
Foo: {
|
||||||
|
screens: {
|
||||||
|
Bar: {
|
||||||
|
path: '/bar/:id/',
|
||||||
|
screens: {
|
||||||
|
404: '*',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'Foo',
|
||||||
|
state: {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'Bar',
|
||||||
|
params: { id: '42' },
|
||||||
|
state: {
|
||||||
|
routes: [{ name: '404' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getPathFromState(state, config)).toBe('/bar/42/404');
|
||||||
|
expect(getPathFromState(getStateFromPath(path, config), config)).toBe(
|
||||||
|
'/bar/42/404'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches wildcard patterns at nested level with exact', () => {
|
||||||
|
const path = '/whatever';
|
||||||
|
const config = {
|
||||||
|
Foo: {
|
||||||
|
screens: {
|
||||||
|
Bar: {
|
||||||
|
path: '/bar/:id/',
|
||||||
|
screens: {
|
||||||
|
404: {
|
||||||
|
path: '*',
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Baz: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'Foo',
|
||||||
|
state: {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'Bar',
|
||||||
|
state: {
|
||||||
|
routes: [{ name: '404' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getPathFromState(state, config)).toBe('/404');
|
||||||
|
expect(getPathFromState(getStateFromPath(path, config), config)).toBe('/404');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tries to match wildcard patterns at the end', () => {
|
||||||
|
const path = '/bar/42/test';
|
||||||
|
const config = {
|
||||||
|
Foo: {
|
||||||
|
screens: {
|
||||||
|
Bar: {
|
||||||
|
path: '/bar/:id/',
|
||||||
|
screens: {
|
||||||
|
404: '*',
|
||||||
|
Test: 'test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'Foo',
|
||||||
|
state: {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'Bar',
|
||||||
|
params: { id: '42' },
|
||||||
|
state: {
|
||||||
|
routes: [{ name: 'Test' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getPathFromState(state, config)).toBe(path);
|
||||||
|
expect(getPathFromState(getStateFromPath(path, config), config)).toBe(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses nearest parent wildcard match for unmatched paths', () => {
|
||||||
|
const path = '/bar/42/baz/test';
|
||||||
|
const config = {
|
||||||
|
Foo: {
|
||||||
|
screens: {
|
||||||
|
Bar: {
|
||||||
|
path: '/bar/:id/',
|
||||||
|
screens: {
|
||||||
|
Baz: 'baz',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: '*',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'Foo',
|
||||||
|
state: {
|
||||||
|
routes: [{ name: '404' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getPathFromState(state, config)).toBe('/404');
|
||||||
|
expect(getPathFromState(getStateFromPath(path, config), config)).toBe('/404');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1883,3 +1883,183 @@ it('ignores extra slashes in the pattern', () => {
|
|||||||
state
|
state
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('matches wildcard patterns at root', () => {
|
||||||
|
const path = '/test/bar/42/whatever';
|
||||||
|
const config = {
|
||||||
|
404: '*',
|
||||||
|
Foo: {
|
||||||
|
screens: {
|
||||||
|
Bar: {
|
||||||
|
path: '/bar/:id/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
routes: [{ name: '404' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getStateFromPath(path, config)).toEqual(state);
|
||||||
|
expect(getStateFromPath(getPathFromState(state, config), config)).toEqual(
|
||||||
|
state
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches wildcard patterns at nested level', () => {
|
||||||
|
const path = '/bar/42/whatever/baz/initt';
|
||||||
|
const config = {
|
||||||
|
Foo: {
|
||||||
|
screens: {
|
||||||
|
Bar: {
|
||||||
|
path: '/bar/:id/',
|
||||||
|
screens: {
|
||||||
|
404: '*',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'Foo',
|
||||||
|
state: {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'Bar',
|
||||||
|
params: { id: '42' },
|
||||||
|
state: {
|
||||||
|
routes: [{ name: '404' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getStateFromPath(path, config)).toEqual(state);
|
||||||
|
expect(getStateFromPath(getPathFromState(state, config), config)).toEqual(
|
||||||
|
state
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches wildcard patterns at nested level with exact', () => {
|
||||||
|
const path = '/whatever';
|
||||||
|
const config = {
|
||||||
|
Foo: {
|
||||||
|
screens: {
|
||||||
|
Bar: {
|
||||||
|
path: '/bar/:id/',
|
||||||
|
screens: {
|
||||||
|
404: {
|
||||||
|
path: '*',
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Baz: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'Foo',
|
||||||
|
state: {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'Bar',
|
||||||
|
state: {
|
||||||
|
routes: [{ name: '404' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getStateFromPath(path, config)).toEqual(state);
|
||||||
|
expect(getStateFromPath(getPathFromState(state, config), config)).toEqual(
|
||||||
|
state
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tries to match wildcard patterns at the end', () => {
|
||||||
|
const path = '/bar/42/test';
|
||||||
|
const config = {
|
||||||
|
Foo: {
|
||||||
|
screens: {
|
||||||
|
Bar: {
|
||||||
|
path: '/bar/:id/',
|
||||||
|
screens: {
|
||||||
|
404: '*',
|
||||||
|
Test: 'test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'Foo',
|
||||||
|
state: {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'Bar',
|
||||||
|
params: { id: '42' },
|
||||||
|
state: {
|
||||||
|
routes: [{ name: 'Test' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getStateFromPath(path, config)).toEqual(state);
|
||||||
|
expect(getStateFromPath(getPathFromState(state, config), config)).toEqual(
|
||||||
|
state
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses nearest parent wildcard match for unmatched paths', () => {
|
||||||
|
const path = '/bar/42/baz/test';
|
||||||
|
const config = {
|
||||||
|
Foo: {
|
||||||
|
screens: {
|
||||||
|
Bar: {
|
||||||
|
path: '/bar/:id/',
|
||||||
|
screens: {
|
||||||
|
Baz: 'baz',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: '*',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'Foo',
|
||||||
|
state: {
|
||||||
|
routes: [{ name: '404' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getStateFromPath(path, config)).toEqual(state);
|
||||||
|
expect(getStateFromPath(getPathFromState(state, config), config)).toEqual(
|
||||||
|
state
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ type ConfigItem = {
|
|||||||
screens?: Record<string, ConfigItem>;
|
screens?: Record<string, ConfigItem>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getActiveRoute = (state: State): { name: string; params?: object } => {
|
||||||
|
const route =
|
||||||
|
typeof state.index === 'number'
|
||||||
|
? state.routes[state.index]
|
||||||
|
: state.routes[state.routes.length - 1];
|
||||||
|
|
||||||
|
if (route.state) {
|
||||||
|
return getActiveRoute(route.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return route;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility to serialize a navigation state object to a path string.
|
* Utility to serialize a navigation state object to a path string.
|
||||||
*
|
*
|
||||||
@@ -69,7 +82,8 @@ export default function getPathFromState(
|
|||||||
|
|
||||||
let pattern: string | undefined;
|
let pattern: string | undefined;
|
||||||
|
|
||||||
let currentParams: Record<string, any> = { ...route.params };
|
let focusedParams: Record<string, any> | undefined;
|
||||||
|
let focusedRoute = getActiveRoute(state);
|
||||||
let currentOptions = configs;
|
let currentOptions = configs;
|
||||||
|
|
||||||
// Keep all the route names that appeared during going deeper in config in case the pattern is resolved to undefined
|
// Keep all the route names that appeared during going deeper in config in case the pattern is resolved to undefined
|
||||||
@@ -85,7 +99,7 @@ export default function getPathFromState(
|
|||||||
if (route.params) {
|
if (route.params) {
|
||||||
const stringify = currentOptions[route.name]?.stringify;
|
const stringify = currentOptions[route.name]?.stringify;
|
||||||
|
|
||||||
currentParams = fromEntries(
|
const currentParams = fromEntries(
|
||||||
Object.entries(route.params).map(([key, value]) => [
|
Object.entries(route.params).map(([key, value]) => [
|
||||||
key,
|
key,
|
||||||
stringify?.[key] ? stringify[key](value) : String(value),
|
stringify?.[key] ? stringify[key](value) : String(value),
|
||||||
@@ -95,6 +109,26 @@ export default function getPathFromState(
|
|||||||
if (pattern) {
|
if (pattern) {
|
||||||
Object.assign(allParams, currentParams);
|
Object.assign(allParams, currentParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (focusedRoute === route) {
|
||||||
|
// If this is the focused route, keep the params for later use
|
||||||
|
// We save it here since it's been stringified already
|
||||||
|
focusedParams = { ...currentParams };
|
||||||
|
|
||||||
|
pattern
|
||||||
|
?.split('/')
|
||||||
|
.filter((p) => p.startsWith(':'))
|
||||||
|
// eslint-disable-next-line no-loop-func
|
||||||
|
.forEach((p) => {
|
||||||
|
const name = getParamName(p);
|
||||||
|
|
||||||
|
// Remove the params present in the pattern since we'll only use the rest for query string
|
||||||
|
if (focusedParams) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||||
|
delete focusedParams[name];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there is no `screens` property or no nested state, we return pattern
|
// If there is no `screens` property or no nested state, we return pattern
|
||||||
@@ -128,18 +162,19 @@ export default function getPathFromState(
|
|||||||
path += pattern
|
path += pattern
|
||||||
.split('/')
|
.split('/')
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
const name = p.replace(/^:/, '').replace(/\?$/, '');
|
const name = getParamName(p);
|
||||||
|
|
||||||
|
// We don't know what to show for wildcard patterns
|
||||||
|
// Showing the route name seems ok, though whatever we show here will be incorrect
|
||||||
|
// Since the page doesn't actually exist
|
||||||
|
if (p === '*') {
|
||||||
|
return route.name;
|
||||||
|
}
|
||||||
|
|
||||||
// If the path has a pattern for a param, put the param in the path
|
// If the path has a pattern for a param, put the param in the path
|
||||||
if (p.startsWith(':')) {
|
if (p.startsWith(':')) {
|
||||||
const value = allParams[name];
|
const value = allParams[name];
|
||||||
|
|
||||||
// Remove the used value from the params object since we'll use the rest for query string
|
|
||||||
if (currentParams) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
||||||
delete currentParams[name];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value === undefined && p.endsWith('?')) {
|
if (value === undefined && p.endsWith('?')) {
|
||||||
// Optional params without value assigned in route.params should be ignored
|
// Optional params without value assigned in route.params should be ignored
|
||||||
return '';
|
return '';
|
||||||
@@ -155,17 +190,21 @@ export default function getPathFromState(
|
|||||||
path += encodeURIComponent(route.name);
|
path += encodeURIComponent(route.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!focusedParams) {
|
||||||
|
focusedParams = focusedRoute.params;
|
||||||
|
}
|
||||||
|
|
||||||
if (route.state) {
|
if (route.state) {
|
||||||
path += '/';
|
path += '/';
|
||||||
} else if (currentParams) {
|
} else if (focusedParams) {
|
||||||
for (let param in currentParams) {
|
for (let param in focusedParams) {
|
||||||
if (currentParams[param] === 'undefined') {
|
if (focusedParams[param] === 'undefined') {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||||
delete currentParams[param];
|
delete focusedParams[param];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = queryString.stringify(currentParams);
|
const query = queryString.stringify(focusedParams);
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
path += `?${query}`;
|
path += `?${query}`;
|
||||||
@@ -189,6 +228,9 @@ const fromEntries = <K extends string, V>(entries: (readonly [K, V])[]) =>
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<K, V>);
|
}, {} as Record<K, V>);
|
||||||
|
|
||||||
|
const getParamName = (pattern: string) =>
|
||||||
|
pattern.replace(/^:/, '').replace(/\?$/, '');
|
||||||
|
|
||||||
const joinPaths = (...paths: string[]): string =>
|
const joinPaths = (...paths: string[]): string =>
|
||||||
([] as string[])
|
([] as string[])
|
||||||
.concat(...paths.map((p) => p.split('/')))
|
.concat(...paths.map((p) => p.split('/')))
|
||||||
|
|||||||
@@ -59,11 +59,46 @@ export default function getStateFromPath(
|
|||||||
createNormalizedConfigs(key, options, [], initialRoutes)
|
createNormalizedConfigs(key, options, [], initialRoutes)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.sort(
|
.sort((a, b) => {
|
||||||
(a, b) =>
|
// Sort config so that:
|
||||||
// Sort configs so the most exhaustive is always first to be chosen
|
// - the most exhaustive ones are always at the beginning
|
||||||
b.pattern.split('/').length - a.pattern.split('/').length
|
// - patterns with wildcard are always at the end
|
||||||
);
|
|
||||||
|
// If one of the patterns starts with the other, it's more exhaustive
|
||||||
|
// So move it up
|
||||||
|
if (a.pattern.startsWith(b.pattern)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b.pattern.startsWith(a.pattern)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aParts = a.pattern.split('/');
|
||||||
|
const bParts = b.pattern.split('/');
|
||||||
|
|
||||||
|
const aWildcardIndex = aParts.indexOf('*');
|
||||||
|
const bWildcardIndex = bParts.indexOf('*');
|
||||||
|
|
||||||
|
// If only one of the patterns has a wildcard, move it down in the list
|
||||||
|
if (aWildcardIndex === -1 && bWildcardIndex !== -1) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aWildcardIndex !== -1 && bWildcardIndex === -1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aWildcardIndex === bWildcardIndex) {
|
||||||
|
// If `b` has more `/`, it's more exhaustive
|
||||||
|
// So we move it up in the list
|
||||||
|
return bParts.length - aParts.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the wildcard appears later in the pattern (has higher index), it's more specific
|
||||||
|
// So we move it up in the list
|
||||||
|
return bWildcardIndex - aWildcardIndex;
|
||||||
|
});
|
||||||
|
|
||||||
let remaining = path
|
let remaining = path
|
||||||
.replace(/\/+/g, '/') // Replace multiple slash (//) with single ones
|
.replace(/\/+/g, '/') // Replace multiple slash (//) with single ones
|
||||||
@@ -104,41 +139,37 @@ export default function getStateFromPath(
|
|||||||
let result: PartialState<NavigationState> | undefined;
|
let result: PartialState<NavigationState> | undefined;
|
||||||
let current: PartialState<NavigationState> | undefined;
|
let current: PartialState<NavigationState> | undefined;
|
||||||
|
|
||||||
|
// We try to match the paths in 2 passes
|
||||||
|
// In first pass, we match the whole path against the regex instead of segments
|
||||||
|
// This makes sure matches such as wildcard will catch any unmatched routes, even if nested
|
||||||
|
const { routeNames, allParams, remainingPath } = matchAgainstConfigs(
|
||||||
|
remaining,
|
||||||
|
configs.map((c) => ({
|
||||||
|
...c,
|
||||||
|
// Add `$` to the regex to make sure it matches till end of the path and not just beginning
|
||||||
|
regex: c.regex ? new RegExp(c.regex.source + '$') : undefined,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (routeNames !== undefined) {
|
||||||
|
// This will always be empty if full path matched
|
||||||
|
remaining = remainingPath;
|
||||||
|
current = createNestedStateObject(
|
||||||
|
createRouteObjects(configs, routeNames, allParams),
|
||||||
|
initialRoutes
|
||||||
|
);
|
||||||
|
result = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In second pass, we divide the path into segments and match piece by piece
|
||||||
|
// This preserves the old behaviour, but we should remove it in next major
|
||||||
while (remaining) {
|
while (remaining) {
|
||||||
let routeNames: string[] | undefined;
|
let { routeNames, allParams, remainingPath } = matchAgainstConfigs(
|
||||||
let allParams: Record<string, any> | undefined;
|
remaining,
|
||||||
|
configs
|
||||||
|
);
|
||||||
|
|
||||||
// Go through all configs, and see if the next path segment matches our regex
|
remaining = remainingPath;
|
||||||
for (const config of configs) {
|
|
||||||
if (!config.regex) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = remaining.match(config.regex);
|
|
||||||
|
|
||||||
// If our regex matches, we need to extract params from the path
|
|
||||||
if (match) {
|
|
||||||
routeNames = [...config.routeNames];
|
|
||||||
|
|
||||||
const paramPatterns = config.pattern
|
|
||||||
.split('/')
|
|
||||||
.filter((p) => p.startsWith(':'));
|
|
||||||
|
|
||||||
if (paramPatterns.length) {
|
|
||||||
allParams = paramPatterns.reduce<Record<string, any>>((acc, p, i) => {
|
|
||||||
const value = match![(i + 1) * 2].replace(/\//, ''); // The param segments appear every second item starting from 2 in the regex match result
|
|
||||||
|
|
||||||
acc[p] = value;
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining = remaining.replace(match[1], '');
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we hadn't matched any segments earlier, use the path as route name
|
// If we hadn't matched any segments earlier, use the path as route name
|
||||||
if (routeNames === undefined) {
|
if (routeNames === undefined) {
|
||||||
@@ -150,43 +181,7 @@ export default function getStateFromPath(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = createNestedStateObject(
|
const state = createNestedStateObject(
|
||||||
routeNames.map((name) => {
|
createRouteObjects(configs, routeNames, allParams),
|
||||||
const config = configs.find((c) => c.screen === name);
|
|
||||||
|
|
||||||
let params: object | undefined;
|
|
||||||
|
|
||||||
if (allParams && config?.path) {
|
|
||||||
const pattern = config.path;
|
|
||||||
|
|
||||||
if (pattern) {
|
|
||||||
const paramPatterns = pattern
|
|
||||||
.split('/')
|
|
||||||
.filter((p) => p.startsWith(':'));
|
|
||||||
|
|
||||||
if (paramPatterns.length) {
|
|
||||||
params = paramPatterns.reduce<Record<string, any>>((acc, p) => {
|
|
||||||
const key = p.replace(/^:/, '').replace(/\?$/, '');
|
|
||||||
const value = allParams![p];
|
|
||||||
|
|
||||||
if (value) {
|
|
||||||
acc[key] =
|
|
||||||
config.parse && config.parse[key]
|
|
||||||
? config.parse[key](value)
|
|
||||||
: value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params && Object.keys(params).length) {
|
|
||||||
return { name, params };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name };
|
|
||||||
}),
|
|
||||||
initialRoutes
|
initialRoutes
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -229,6 +224,46 @@ const joinPaths = (...paths: string[]): string =>
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('/');
|
.join('/');
|
||||||
|
|
||||||
|
const matchAgainstConfigs = (remaining: string, configs: RouteConfig[]) => {
|
||||||
|
let routeNames: string[] | undefined;
|
||||||
|
let allParams: Record<string, any> | undefined;
|
||||||
|
let remainingPath = remaining;
|
||||||
|
|
||||||
|
// Go through all configs, and see if the next path segment matches our regex
|
||||||
|
for (const config of configs) {
|
||||||
|
if (!config.regex) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = remainingPath.match(config.regex);
|
||||||
|
|
||||||
|
// If our regex matches, we need to extract params from the path
|
||||||
|
if (match) {
|
||||||
|
routeNames = [...config.routeNames];
|
||||||
|
|
||||||
|
const paramPatterns = config.pattern
|
||||||
|
.split('/')
|
||||||
|
.filter((p) => p.startsWith(':'));
|
||||||
|
|
||||||
|
if (paramPatterns.length) {
|
||||||
|
allParams = paramPatterns.reduce<Record<string, any>>((acc, p, i) => {
|
||||||
|
const value = match![(i + 1) * 2].replace(/\//, ''); // The param segments appear every second item starting from 2 in the regex match result
|
||||||
|
|
||||||
|
acc[p] = value;
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingPath = remainingPath.replace(match[1], '');
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { routeNames, allParams, remainingPath };
|
||||||
|
};
|
||||||
|
|
||||||
const createNormalizedConfigs = (
|
const createNormalizedConfigs = (
|
||||||
screen: string,
|
screen: string,
|
||||||
routeConfig: PathConfig,
|
routeConfig: PathConfig,
|
||||||
@@ -311,7 +346,7 @@ const createConfigItem = (
|
|||||||
return `(([^/]+\\/)${it.endsWith('?') ? '?' : ''})`;
|
return `(([^/]+\\/)${it.endsWith('?') ? '?' : ''})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${escape(it)}\\/`;
|
return `${it === '*' ? '.*' : escape(it)}\\/`;
|
||||||
})
|
})
|
||||||
.join('')})`
|
.join('')})`
|
||||||
)
|
)
|
||||||
@@ -433,6 +468,49 @@ const createNestedStateObject = (
|
|||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createRouteObjects = (
|
||||||
|
configs: RouteConfig[],
|
||||||
|
routeNames: string[],
|
||||||
|
allParams?: Record<string, any>
|
||||||
|
) =>
|
||||||
|
routeNames.map((name) => {
|
||||||
|
const config = configs.find((c) => c.screen === name);
|
||||||
|
|
||||||
|
let params: object | undefined;
|
||||||
|
|
||||||
|
if (allParams && config?.path) {
|
||||||
|
const pattern = config.path;
|
||||||
|
|
||||||
|
if (pattern) {
|
||||||
|
const paramPatterns = pattern
|
||||||
|
.split('/')
|
||||||
|
.filter((p) => p.startsWith(':'));
|
||||||
|
|
||||||
|
if (paramPatterns.length) {
|
||||||
|
params = paramPatterns.reduce<Record<string, any>>((acc, p) => {
|
||||||
|
const key = p.replace(/^:/, '').replace(/\?$/, '');
|
||||||
|
const value = allParams![p];
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
acc[key] =
|
||||||
|
config.parse && config.parse[key]
|
||||||
|
? config.parse[key](value)
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params && Object.keys(params).length) {
|
||||||
|
return { name, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name };
|
||||||
|
});
|
||||||
|
|
||||||
const findFocusedRoute = (state: InitialState) => {
|
const findFocusedRoute = (state: InitialState) => {
|
||||||
let current: InitialState | undefined = state;
|
let current: InitialState | undefined = state;
|
||||||
|
|
||||||
|
|||||||
7
packages/core/src/isArrayEqual.tsx
Normal file
7
packages/core/src/isArrayEqual.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Compare two arrays with primitive values as the content.
|
||||||
|
* We need to make sure that both values and order match.
|
||||||
|
*/
|
||||||
|
export default function isArrayEqual(a: any[], b: any[]) {
|
||||||
|
return a.length === b.length && a.every((it, index) => it === b[index]);
|
||||||
|
}
|
||||||
@@ -152,7 +152,7 @@ type NavigationHelpersCommon<
|
|||||||
* @param [params] Params object for the route.
|
* @param [params] Params object for the route.
|
||||||
*/
|
*/
|
||||||
navigate<RouteName extends keyof ParamList>(
|
navigate<RouteName extends keyof ParamList>(
|
||||||
...args: ParamList[RouteName] extends undefined | any
|
...args: undefined extends ParamList[RouteName]
|
||||||
? [RouteName] | [RouteName, ParamList[RouteName]]
|
? [RouteName] | [RouteName, ParamList[RouteName]]
|
||||||
: [RouteName, ParamList[RouteName]]
|
: [RouteName, ParamList[RouteName]]
|
||||||
): void;
|
): void;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import useStateGetters from './useStateGetters';
|
|||||||
import useOnGetState from './useOnGetState';
|
import useOnGetState from './useOnGetState';
|
||||||
import useScheduleUpdate from './useScheduleUpdate';
|
import useScheduleUpdate from './useScheduleUpdate';
|
||||||
import useCurrentRender from './useCurrentRender';
|
import useCurrentRender from './useCurrentRender';
|
||||||
|
import isArrayEqual from './isArrayEqual';
|
||||||
|
|
||||||
// This is to make TypeScript compiler happy
|
// This is to make TypeScript compiler happy
|
||||||
// eslint-disable-next-line babel/no-unused-expressions
|
// eslint-disable-next-line babel/no-unused-expressions
|
||||||
@@ -48,13 +49,6 @@ type NavigatorRoute = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare two arrays with primitive values as the content.
|
|
||||||
* We need to make sure that both values and order match.
|
|
||||||
*/
|
|
||||||
const isArrayEqual = (a: any[], b: any[]) =>
|
|
||||||
a.length === b.length && a.every((it, index) => it === b[index]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract route config object from React children elements.
|
* Extract route config object from React children elements.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
|||||||
import { NavigationState } from '@react-navigation/routers';
|
import { NavigationState } from '@react-navigation/routers';
|
||||||
import NavigationBuilderContext from './NavigationBuilderContext';
|
import NavigationBuilderContext from './NavigationBuilderContext';
|
||||||
import NavigationRouteContext from './NavigationRouteContext';
|
import NavigationRouteContext from './NavigationRouteContext';
|
||||||
|
import isArrayEqual from './isArrayEqual';
|
||||||
|
|
||||||
export default function useOnGetState({
|
export default function useOnGetState({
|
||||||
getStateForRoute,
|
getStateForRoute,
|
||||||
@@ -16,13 +17,23 @@ export default function useOnGetState({
|
|||||||
|
|
||||||
const getRehydratedState = React.useCallback(() => {
|
const getRehydratedState = React.useCallback(() => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
return {
|
|
||||||
...state,
|
// Avoid returning new route objects if we don't need to
|
||||||
routes: state.routes.map((route) => ({
|
const routes = state.routes.map((route) => {
|
||||||
...route,
|
const childState = getStateForRoute(route.key);
|
||||||
state: getStateForRoute(route.key),
|
|
||||||
})),
|
if (route.state === childState) {
|
||||||
};
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...route, state: childState };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isArrayEqual(state.routes, routes)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...state, routes };
|
||||||
}, [getState, getStateForRoute]);
|
}, [getState, getStateForRoute]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -3,6 +3,17 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
## [5.8.2](https://github.com/react-navigation/react-navigation/tree/master/packages/drawer/compare/@react-navigation/drawer@5.8.1...@react-navigation/drawer@5.8.2) (2020-06-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* typo on drawerPosition default props ([#8357](https://github.com/react-navigation/react-navigation/tree/master/packages/drawer/issues/8357)) ([762cc44](https://github.com/react-navigation/react-navigation/tree/master/packages/drawer/commit/762cc4457842182189eeac84aedbb88169452e1e))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [5.8.1](https://github.com/react-navigation/react-navigation/tree/master/packages/drawer/compare/@react-navigation/drawer@5.8.0...@react-navigation/drawer@5.8.1) (2020-05-27)
|
## [5.8.1](https://github.com/react-navigation/react-navigation/tree/master/packages/drawer/compare/@react-navigation/drawer@5.8.0...@react-navigation/drawer@5.8.1) (2020-05-27)
|
||||||
|
|
||||||
**Note:** Version bump only for package @react-navigation/drawer
|
**Note:** Version bump only for package @react-navigation/drawer
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@react-navigation/drawer",
|
"name": "@react-navigation/drawer",
|
||||||
"description": "Drawer navigator component with animated transitions and gesturess",
|
"description": "Drawer navigator component with animated transitions and gesturess",
|
||||||
"version": "5.8.1",
|
"version": "5.8.2",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"react-native-component",
|
"react-native-component",
|
||||||
"react-component",
|
"react-component",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-native-community/bob": "^0.14.3",
|
"@react-native-community/bob": "^0.14.3",
|
||||||
"@react-navigation/native": "^5.5.0",
|
"@react-navigation/native": "^5.5.1",
|
||||||
"@types/react": "^16.9.34",
|
"@types/react": "^16.9.34",
|
||||||
"@types/react-native": "^0.62.7",
|
"@types/react-native": "^0.62.7",
|
||||||
"del-cli": "^3.0.0",
|
"del-cli": "^3.0.0",
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ type Props = {
|
|||||||
|
|
||||||
export default class DrawerView extends React.Component<Props> {
|
export default class DrawerView extends React.Component<Props> {
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
drawerPostion: I18nManager.isRTL ? 'left' : 'right',
|
drawerPosition: I18nManager.isRTL ? 'left' : 'right',
|
||||||
drawerType: 'front',
|
drawerType: 'front',
|
||||||
gestureEnabled: true,
|
gestureEnabled: true,
|
||||||
swipeEnabled: Platform.OS !== 'web',
|
swipeEnabled: Platform.OS !== 'web',
|
||||||
|
|||||||
@@ -238,7 +238,6 @@ export default function DrawerView({
|
|||||||
renderDrawerContent={renderNavigationView}
|
renderDrawerContent={renderNavigationView}
|
||||||
renderSceneContent={renderContent}
|
renderSceneContent={renderContent}
|
||||||
keyboardDismissMode={keyboardDismissMode}
|
keyboardDismissMode={keyboardDismissMode}
|
||||||
drawerPostion={drawerPosition}
|
|
||||||
dimensions={dimensions}
|
dimensions={dimensions}
|
||||||
/>
|
/>
|
||||||
</DrawerOpenContext.Provider>
|
</DrawerOpenContext.Provider>
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
## [5.2.10](https://github.com/react-navigation/react-navigation/tree/master/packages/material-bottom-tabs/compare/@react-navigation/material-bottom-tabs@5.2.9...@react-navigation/material-bottom-tabs@5.2.10) (2020-06-06)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @react-navigation/material-bottom-tabs
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [5.2.9](https://github.com/react-navigation/react-navigation/tree/master/packages/material-bottom-tabs/compare/@react-navigation/material-bottom-tabs@5.2.8...@react-navigation/material-bottom-tabs@5.2.9) (2020-05-27)
|
## [5.2.9](https://github.com/react-navigation/react-navigation/tree/master/packages/material-bottom-tabs/compare/@react-navigation/material-bottom-tabs@5.2.8...@react-navigation/material-bottom-tabs@5.2.9) (2020-05-27)
|
||||||
|
|
||||||
**Note:** Version bump only for package @react-navigation/material-bottom-tabs
|
**Note:** Version bump only for package @react-navigation/material-bottom-tabs
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@react-navigation/material-bottom-tabs",
|
"name": "@react-navigation/material-bottom-tabs",
|
||||||
"description": "Integration for bottom navigation component from react-native-paper",
|
"description": "Integration for bottom navigation component from react-native-paper",
|
||||||
"version": "5.2.9",
|
"version": "5.2.10",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"react-native-component",
|
"react-native-component",
|
||||||
"react-component",
|
"react-component",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-native-community/bob": "^0.14.3",
|
"@react-native-community/bob": "^0.14.3",
|
||||||
"@react-navigation/native": "^5.5.0",
|
"@react-navigation/native": "^5.5.1",
|
||||||
"@types/react": "^16.9.34",
|
"@types/react": "^16.9.34",
|
||||||
"@types/react-native": "^0.62.7",
|
"@types/react-native": "^0.62.7",
|
||||||
"@types/react-native-vector-icons": "^6.4.5",
|
"@types/react-native-vector-icons": "^6.4.5",
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
## [5.2.10](https://github.com/react-navigation/react-navigation/tree/master/packages/material-top-tabs/compare/@react-navigation/material-top-tabs@5.2.9...@react-navigation/material-top-tabs@5.2.10) (2020-06-06)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @react-navigation/material-top-tabs
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [5.2.9](https://github.com/react-navigation/react-navigation/tree/master/packages/material-top-tabs/compare/@react-navigation/material-top-tabs@5.2.8...@react-navigation/material-top-tabs@5.2.9) (2020-05-27)
|
## [5.2.9](https://github.com/react-navigation/react-navigation/tree/master/packages/material-top-tabs/compare/@react-navigation/material-top-tabs@5.2.8...@react-navigation/material-top-tabs@5.2.9) (2020-05-27)
|
||||||
|
|
||||||
**Note:** Version bump only for package @react-navigation/material-top-tabs
|
**Note:** Version bump only for package @react-navigation/material-top-tabs
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@react-navigation/material-top-tabs",
|
"name": "@react-navigation/material-top-tabs",
|
||||||
"description": "Integration for the animated tab view component from react-native-tab-view",
|
"description": "Integration for the animated tab view component from react-native-tab-view",
|
||||||
"version": "5.2.9",
|
"version": "5.2.10",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"react-native-component",
|
"react-native-component",
|
||||||
"react-component",
|
"react-component",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-native-community/bob": "^0.14.3",
|
"@react-native-community/bob": "^0.14.3",
|
||||||
"@react-navigation/native": "^5.5.0",
|
"@react-navigation/native": "^5.5.1",
|
||||||
"@types/react": "^16.9.34",
|
"@types/react": "^16.9.34",
|
||||||
"@types/react-native": "^0.62.7",
|
"@types/react-native": "^0.62.7",
|
||||||
"del-cli": "^3.0.0",
|
"del-cli": "^3.0.0",
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
## [5.5.1](https://github.com/react-navigation/react-navigation/tree/master/packages/native/compare/@react-navigation/native@5.5.0...@react-navigation/native@5.5.1) (2020-06-06)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @react-navigation/native
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# [5.5.0](https://github.com/react-navigation/react-navigation/tree/master/packages/native/compare/@react-navigation/native@5.4.3...@react-navigation/native@5.5.0) (2020-05-27)
|
# [5.5.0](https://github.com/react-navigation/react-navigation/tree/master/packages/native/compare/@react-navigation/native@5.4.3...@react-navigation/native@5.5.0) (2020-05-27)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@react-navigation/native",
|
"name": "@react-navigation/native",
|
||||||
"description": "React Native integration for React Navigation",
|
"description": "React Native integration for React Navigation",
|
||||||
"version": "5.5.0",
|
"version": "5.5.1",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"react-native",
|
"react-native",
|
||||||
"react-navigation",
|
"react-navigation",
|
||||||
@@ -33,7 +33,8 @@
|
|||||||
"clean": "del lib"
|
"clean": "del lib"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-navigation/core": "^5.9.0"
|
"@react-navigation/core": "^5.10.0",
|
||||||
|
"nanoid": "^3.1.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-native-community/bob": "^0.14.3",
|
"@react-native-community/bob": "^0.14.3",
|
||||||
|
|||||||
69
packages/native/src/__mocks__/window.tsx
Normal file
69
packages/native/src/__mocks__/window.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
const location = new URL('', 'http://example.com');
|
||||||
|
|
||||||
|
let listeners: (() => void)[] = [];
|
||||||
|
let entries = [{ state: null, href: location.href }];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
let currentState: any = null;
|
||||||
|
|
||||||
|
const history = {
|
||||||
|
get state() {
|
||||||
|
return currentState;
|
||||||
|
},
|
||||||
|
|
||||||
|
pushState(state: any, _: string, path: string) {
|
||||||
|
Object.assign(location, new URL(path, location.origin));
|
||||||
|
|
||||||
|
currentState = state;
|
||||||
|
entries = entries.slice(0, index + 1);
|
||||||
|
entries.push({ state, href: location.href });
|
||||||
|
index = entries.length - 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
replaceState(state: any, _: string, path: string) {
|
||||||
|
Object.assign(location, new URL(path, location.origin));
|
||||||
|
|
||||||
|
currentState = state;
|
||||||
|
entries[index] = { state, href: location.href };
|
||||||
|
},
|
||||||
|
|
||||||
|
go(n: number) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (
|
||||||
|
(n > 0 && n < entries.length - index) ||
|
||||||
|
(n < 0 && Math.abs(n) <= index)
|
||||||
|
) {
|
||||||
|
index += n;
|
||||||
|
Object.assign(location, new URL(entries[index].href));
|
||||||
|
listeners.forEach((cb) => cb);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
back() {
|
||||||
|
this.go(-1);
|
||||||
|
},
|
||||||
|
|
||||||
|
forward() {
|
||||||
|
this.go(1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEventListener = (type: 'popstate', listener: () => void) => {
|
||||||
|
if (type === 'popstate') {
|
||||||
|
listeners.push(listener);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEventListener = (type: 'popstate', listener: () => void) => {
|
||||||
|
if (type === 'popstate') {
|
||||||
|
listeners = listeners.filter((cb) => cb !== listener);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
location,
|
||||||
|
history,
|
||||||
|
addEventListener,
|
||||||
|
removeEventListener,
|
||||||
|
};
|
||||||
149
packages/native/src/__tests__/NavigationContainer.test.tsx
Normal file
149
packages/native/src/__tests__/NavigationContainer.test.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
useNavigationBuilder,
|
||||||
|
createNavigatorFactory,
|
||||||
|
StackRouter,
|
||||||
|
TabRouter,
|
||||||
|
NavigationHelpersContext,
|
||||||
|
NavigationContainerRef,
|
||||||
|
} from '@react-navigation/core';
|
||||||
|
import { act, render } from 'react-native-testing-library';
|
||||||
|
import NavigationContainer from '../NavigationContainer';
|
||||||
|
import window from '../__mocks__/window';
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
global.window = window;
|
||||||
|
|
||||||
|
// We want to use the web version of useLinking
|
||||||
|
jest.mock('../useLinking', () => require('../useLinking.tsx').default);
|
||||||
|
|
||||||
|
it('integrates with the history API', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
const createStackNavigator = createNavigatorFactory((props: any) => {
|
||||||
|
const { navigation, state, descriptors } = useNavigationBuilder(
|
||||||
|
StackRouter,
|
||||||
|
props
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationHelpersContext.Provider value={navigation}>
|
||||||
|
{state.routes.map((route, i) => (
|
||||||
|
<div key={route.key} aria-current={state.index === i || undefined}>
|
||||||
|
{descriptors[route.key].render()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</NavigationHelpersContext.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTabNavigator = createNavigatorFactory((props: any) => {
|
||||||
|
const { navigation, state, descriptors } = useNavigationBuilder(
|
||||||
|
TabRouter,
|
||||||
|
props
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationHelpersContext.Provider value={navigation}>
|
||||||
|
{state.routes.map((route, i) => (
|
||||||
|
<div key={route.key} aria-current={state.index === i || undefined}>
|
||||||
|
{descriptors[route.key].render()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</NavigationHelpersContext.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const Stack = createStackNavigator();
|
||||||
|
const Tab = createTabNavigator();
|
||||||
|
|
||||||
|
const TestScreen = ({ route }: any): any =>
|
||||||
|
`${route.name} ${JSON.stringify(route.params)}`;
|
||||||
|
|
||||||
|
const linking = {
|
||||||
|
prefixes: [],
|
||||||
|
config: {
|
||||||
|
Home: {
|
||||||
|
path: '',
|
||||||
|
initialRouteName: 'Feed',
|
||||||
|
screens: {
|
||||||
|
Profile: ':user',
|
||||||
|
Settings: 'edit',
|
||||||
|
Updates: 'updates',
|
||||||
|
Feed: 'feed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Chat: 'chat',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigation = React.createRef<NavigationContainerRef>();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<NavigationContainer ref={navigation} linking={linking}>
|
||||||
|
<Tab.Navigator>
|
||||||
|
<Tab.Screen name="Home">
|
||||||
|
{() => (
|
||||||
|
<Stack.Navigator initialRouteName="Feed">
|
||||||
|
<Stack.Screen name="Profile" component={TestScreen} />
|
||||||
|
<Stack.Screen name="Settings" component={TestScreen} />
|
||||||
|
<Stack.Screen name="Feed" component={TestScreen} />
|
||||||
|
<Stack.Screen name="Updates" component={TestScreen} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
)}
|
||||||
|
</Tab.Screen>
|
||||||
|
<Tab.Screen name="Chat" component={TestScreen} />
|
||||||
|
</Tab.Navigator>
|
||||||
|
</NavigationContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/feed');
|
||||||
|
|
||||||
|
act(() => navigation.current?.navigate('Profile', { user: 'jane' }));
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/jane');
|
||||||
|
|
||||||
|
act(() => navigation.current?.navigate('Updates'));
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/updates');
|
||||||
|
|
||||||
|
act(() => navigation.current?.goBack());
|
||||||
|
|
||||||
|
jest.runAllTimers();
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/jane');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.history.back();
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/feed');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.history.forward();
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/jane');
|
||||||
|
|
||||||
|
act(() => navigation.current?.navigate('Settings'));
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/edit');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.history.go(-2);
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/feed');
|
||||||
|
|
||||||
|
act(() => navigation.current?.navigate('Settings'));
|
||||||
|
act(() => navigation.current?.navigate('Chat'));
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/chat');
|
||||||
|
|
||||||
|
act(() => navigation.current?.navigate('Home'));
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/edit');
|
||||||
|
});
|
||||||
@@ -6,37 +6,226 @@ import {
|
|||||||
NavigationState,
|
NavigationState,
|
||||||
getActionFromState,
|
getActionFromState,
|
||||||
} from '@react-navigation/core';
|
} from '@react-navigation/core';
|
||||||
|
import { nanoid } from 'nanoid/non-secure';
|
||||||
import ServerContext from './ServerContext';
|
import ServerContext from './ServerContext';
|
||||||
import { LinkingOptions } from './types';
|
import { LinkingOptions } from './types';
|
||||||
|
|
||||||
type ResultState = ReturnType<typeof getStateFromPathDefault>;
|
type ResultState = ReturnType<typeof getStateFromPathDefault>;
|
||||||
|
|
||||||
type HistoryState = { index: number };
|
type HistoryRecord = {
|
||||||
|
// Unique identifier for this record to match it with window.history.state
|
||||||
declare const history: {
|
id: string;
|
||||||
state?: HistoryState;
|
// Navigation state object for the history entry
|
||||||
go(delta: number): void;
|
state: NavigationState;
|
||||||
pushState(state: HistoryState, title: string, url: string): void;
|
// Path of the history entry
|
||||||
replaceState(state: HistoryState, title: string, url: string): void;
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStateLength = (state: NavigationState) => {
|
const createMemoryHistory = () => {
|
||||||
let length = 0;
|
let index = 0;
|
||||||
|
let items: HistoryRecord[] = [];
|
||||||
|
|
||||||
if (state.history) {
|
// Whether there's a `history.go(n)` pending
|
||||||
length = state.history.length;
|
let pending = false;
|
||||||
|
|
||||||
|
const history = {
|
||||||
|
get index(): number {
|
||||||
|
// We store an id in the state instead of an index
|
||||||
|
// Index could get out of sync with in-memory values if page reloads
|
||||||
|
const id = window.history.state?.id;
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const index = items.findIndex((item) => item.id === id);
|
||||||
|
|
||||||
|
return index > -1 ? index : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
get(index: number) {
|
||||||
|
return items[index]?.state;
|
||||||
|
},
|
||||||
|
|
||||||
|
backIndex({ path }: { path: string }) {
|
||||||
|
// We need to find the index from the element before current to get closest path to go back to
|
||||||
|
for (let i = index - 1; i >= 0; i--) {
|
||||||
|
const item = items[i];
|
||||||
|
|
||||||
|
if (item.path === path) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
},
|
||||||
|
|
||||||
|
push({ path, state }: { path: string; state: NavigationState }) {
|
||||||
|
const id = nanoid();
|
||||||
|
|
||||||
|
// When a new entry is pushed, all the existing entries after index will be inaccessible
|
||||||
|
// So we remove any existing entries after the current index to clean them up
|
||||||
|
items = items.slice(0, index + 1);
|
||||||
|
|
||||||
|
items.push({ path, state, id });
|
||||||
|
index = items.length - 1;
|
||||||
|
|
||||||
|
// We pass empty string for title because it's ignored in all browsers except safari
|
||||||
|
// We don't store state object in history.state because:
|
||||||
|
// - browsers have limits on how big it can be, and we don't control the size
|
||||||
|
// - while not recommended, there could be non-serializable data in state
|
||||||
|
window.history.pushState({ id }, '', path);
|
||||||
|
},
|
||||||
|
|
||||||
|
replace({ path, state }: { path: string; state: NavigationState }) {
|
||||||
|
const id = window.history.state?.id ?? nanoid();
|
||||||
|
|
||||||
|
if (items.length) {
|
||||||
|
items[index] = { path, state, id };
|
||||||
} else {
|
} else {
|
||||||
length = state.index + 1;
|
// This is the first time any state modifications are done
|
||||||
|
// So we need to push the entry as there's nothing to replace
|
||||||
|
items.push({ path, state, id });
|
||||||
}
|
}
|
||||||
|
|
||||||
const focusedState = state.routes[state.index].state;
|
window.history.replaceState({ id }, '', path);
|
||||||
|
},
|
||||||
|
|
||||||
if (focusedState && !focusedState.stale) {
|
// `history.go(n)` is asynchronous, there are couple of things to keep in mind:
|
||||||
// If the focused route has history entries, we need to count them as well
|
// - it won't do anything if we can't go `n` steps, the `popstate` event won't fire.
|
||||||
length += getStateLength(focusedState as NavigationState) - 1;
|
// - each `history.go(n)` call will trigger a separate `popstate` event with correct location.
|
||||||
|
// - the `popstate` event fires before the next frame after calling `history.go(n)`.
|
||||||
|
// This method differs from `history.go(n)` in the sense that it'll go back as many steps it can.
|
||||||
|
go(n: number) {
|
||||||
|
if (n > 0) {
|
||||||
|
// We shouldn't go forward more than available index
|
||||||
|
n = Math.min(n, items.length - 1);
|
||||||
|
} else if (n < 0) {
|
||||||
|
// We shouldn't go back more than the index
|
||||||
|
// Otherwise we'll exit the page
|
||||||
|
n = Math.max(n, -Math.max(index + 1, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
return length;
|
if (n === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
index += n;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
pending = true;
|
||||||
|
|
||||||
|
const done = () => {
|
||||||
|
pending = false;
|
||||||
|
|
||||||
|
window.removeEventListener('popstate', done);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve the promise in the next frame
|
||||||
|
// If `popstate` hasn't fired by then, then it wasn't handled
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(done));
|
||||||
|
|
||||||
|
window.addEventListener('popstate', done);
|
||||||
|
window.history.go(n);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// The `popstate` event is triggered when history changes, except `pushState` and `replaceState`
|
||||||
|
// If we call `history.go(n)` ourselves, we don't want it to trigger the listener
|
||||||
|
// Here we normalize it so that only external changes (e.g. user pressing back/forward) trigger the listener
|
||||||
|
listen(listener: () => void) {
|
||||||
|
const onPopState = () => {
|
||||||
|
if (pending) {
|
||||||
|
// This was triggered by `history.go(n)`, we shouldn't call the listener
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listener();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('popstate', onPopState);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('popstate', onPopState);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return history;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the matching navigation state that changed between 2 navigation states
|
||||||
|
* e.g.: a -> b -> c -> d and a -> b -> c -> e -> f, if history in b changed, b is the matching state
|
||||||
|
*/
|
||||||
|
const findMatchingState = <T extends NavigationState>(
|
||||||
|
a: T | undefined,
|
||||||
|
b: T | undefined
|
||||||
|
): [T | undefined, T | undefined] => {
|
||||||
|
if (a === undefined || b === undefined || a.key !== b.key) {
|
||||||
|
return [undefined, undefined];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab and drawer will have `history` property, but stack will have history in `routes`
|
||||||
|
const aHistoryLength = a.history ? a.history.length : a.routes.length;
|
||||||
|
const bHistoryLength = b.history ? b.history.length : b.routes.length;
|
||||||
|
|
||||||
|
const aRoute = a.routes[a.index];
|
||||||
|
const bRoute = b.routes[b.index];
|
||||||
|
|
||||||
|
const aChildState = aRoute.state as T | undefined;
|
||||||
|
const bChildState = bRoute.state as T | undefined;
|
||||||
|
|
||||||
|
// Stop here if this is the state object that changed:
|
||||||
|
// - history length is different
|
||||||
|
// - focused routes are different
|
||||||
|
// - one of them doesn't have child state
|
||||||
|
// - child state keys are different
|
||||||
|
if (
|
||||||
|
aHistoryLength !== bHistoryLength ||
|
||||||
|
aRoute.key !== bRoute.key ||
|
||||||
|
aChildState === undefined ||
|
||||||
|
bChildState === undefined ||
|
||||||
|
aChildState.key !== bChildState.key
|
||||||
|
) {
|
||||||
|
return [a, b];
|
||||||
|
}
|
||||||
|
|
||||||
|
return findMatchingState(aChildState, bChildState);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run async function in series as it's called.
|
||||||
|
*/
|
||||||
|
const series = (cb: () => Promise<void>) => {
|
||||||
|
// Whether we're currently handling a callback
|
||||||
|
let handling = false;
|
||||||
|
let queue: (() => Promise<void>)[] = [];
|
||||||
|
|
||||||
|
const callback = async () => {
|
||||||
|
try {
|
||||||
|
if (handling) {
|
||||||
|
// If we're currently handling a previous event, wait before handling this one
|
||||||
|
// Add the callback to the beginning of the queue
|
||||||
|
queue.unshift(callback);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handling = true;
|
||||||
|
|
||||||
|
await cb();
|
||||||
|
} finally {
|
||||||
|
handling = false;
|
||||||
|
|
||||||
|
if (queue.length) {
|
||||||
|
// If we have queued items, handle the last one
|
||||||
|
const last = queue.pop();
|
||||||
|
|
||||||
|
last?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return callback;
|
||||||
};
|
};
|
||||||
|
|
||||||
let isUsingLinking = false;
|
let isUsingLinking = false;
|
||||||
@@ -70,6 +259,8 @@ export default function useLinking(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [history] = React.useState(createMemoryHistory);
|
||||||
|
|
||||||
// We store these options in ref to avoid re-creating getInitialState and re-subscribing listeners
|
// We store these options in ref to avoid re-creating getInitialState and re-subscribing listeners
|
||||||
// This lets user avoid wrapping the items in `React.useCallback` or `React.useMemo`
|
// This lets user avoid wrapping the items in `React.useCallback` or `React.useMemo`
|
||||||
// Not re-creating `getInitialState` is important coz it makes it easier for the user to use in an effect
|
// Not re-creating `getInitialState` is important coz it makes it easier for the user to use in an effect
|
||||||
@@ -116,203 +307,143 @@ export default function useLinking(
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const previousStateLengthRef = React.useRef<number | undefined>(undefined);
|
const previousStateRef = React.useRef<NavigationState | undefined>(undefined);
|
||||||
const previousHistoryIndexRef = React.useRef(0);
|
const pendingPopStatePathRef = React.useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
const pendingIndexChangeRef = React.useRef<number | undefined>();
|
|
||||||
const pendingStateUpdateRef = React.useRef<boolean>(false);
|
|
||||||
const pendingStateMultiUpdateRef = React.useRef<boolean>(false);
|
|
||||||
|
|
||||||
// If we're navigating ahead >1, we're not restoring whole state,
|
|
||||||
// but just navigate to the selected route not caring about previous routes
|
|
||||||
// therefore if we need to go back, we need to pop screen and navigate to the new one
|
|
||||||
// Possibly, we will need to reuse the same mechanism.
|
|
||||||
// E.g. if we went ahead+4 (numberOfIndicesAhead = 3), and back-2,
|
|
||||||
// actually we need to pop the screen we navigated
|
|
||||||
// and navigate again, setting numberOfIndicesAhead to 1.
|
|
||||||
const numberOfIndicesAhead = React.useRef(0);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const onPopState = () => {
|
return history.listen(() => {
|
||||||
const navigation = ref.current;
|
const navigation = ref.current;
|
||||||
|
|
||||||
if (!navigation || !enabled) {
|
if (!navigation || !enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousHistoryIndex = previousHistoryIndexRef.current;
|
const path = location.pathname + location.search;
|
||||||
const historyIndex = history.state?.index ?? 0;
|
|
||||||
|
|
||||||
previousHistoryIndexRef.current = historyIndex;
|
pendingPopStatePathRef.current = path;
|
||||||
|
|
||||||
if (pendingIndexChangeRef.current === historyIndex) {
|
// When browser back/forward is clicked, we first need to check if state object for this index exists
|
||||||
pendingIndexChangeRef.current = undefined;
|
// If it does we'll reset to that state object
|
||||||
|
// Otherwise, we'll handle it like a regular deep link
|
||||||
|
const recordedState = history.get(history.index);
|
||||||
|
|
||||||
|
if (recordedState) {
|
||||||
|
navigation.resetRoot(recordedState);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = navigation.getRootState();
|
const state = getStateFromPathRef.current(path, configRef.current);
|
||||||
const path = getPathFromStateRef.current(state, configRef.current);
|
|
||||||
|
|
||||||
let canGoBack = true;
|
|
||||||
let numberOfBacks = 0;
|
|
||||||
|
|
||||||
if (previousHistoryIndex === historyIndex) {
|
|
||||||
if (location.pathname + location.search !== path) {
|
|
||||||
pendingStateUpdateRef.current = true;
|
|
||||||
history.replaceState({ index: historyIndex }, '', path);
|
|
||||||
}
|
|
||||||
} else if (previousHistoryIndex > historyIndex) {
|
|
||||||
numberOfBacks =
|
|
||||||
previousHistoryIndex - historyIndex - numberOfIndicesAhead.current;
|
|
||||||
|
|
||||||
if (numberOfBacks > 0) {
|
|
||||||
pendingStateMultiUpdateRef.current = true;
|
|
||||||
|
|
||||||
if (numberOfBacks > 1) {
|
|
||||||
pendingStateMultiUpdateRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingStateUpdateRef.current = true;
|
|
||||||
|
|
||||||
for (let i = 0; i < numberOfBacks; i++) {
|
|
||||||
navigation.goBack();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
canGoBack = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousHistoryIndex < historyIndex || !canGoBack) {
|
|
||||||
if (canGoBack) {
|
|
||||||
numberOfIndicesAhead.current =
|
|
||||||
historyIndex - previousHistoryIndex - 1;
|
|
||||||
} else {
|
|
||||||
navigation.goBack();
|
|
||||||
numberOfIndicesAhead.current -= previousHistoryIndex - historyIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = getStateFromPathRef.current(
|
|
||||||
location.pathname + location.search,
|
|
||||||
configRef.current
|
|
||||||
);
|
|
||||||
|
|
||||||
pendingStateMultiUpdateRef.current = true;
|
|
||||||
|
|
||||||
if (state) {
|
if (state) {
|
||||||
const action = getActionFromState(state);
|
const action = getActionFromState(state);
|
||||||
|
|
||||||
pendingStateUpdateRef.current = true;
|
|
||||||
|
|
||||||
if (action !== undefined) {
|
if (action !== undefined) {
|
||||||
navigation.dispatch(action);
|
navigation.dispatch(action);
|
||||||
} else {
|
} else {
|
||||||
navigation.resetRoot(state);
|
navigation.resetRoot(state);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// if current path didn't return any state, we should revert to initial state
|
||||||
|
navigation.resetRoot(state);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
};
|
}, [enabled, history, ref]);
|
||||||
|
|
||||||
window.addEventListener('popstate', onPopState);
|
|
||||||
|
|
||||||
return () => window.removeEventListener('popstate', onPopState);
|
|
||||||
}, [enabled, ref]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ref.current && previousStateLengthRef.current === undefined) {
|
if (ref.current) {
|
||||||
previousStateLengthRef.current = getStateLength(
|
// We need to record the current metadata on the first render if they aren't set
|
||||||
ref.current.getRootState()
|
// This will allow the initial state to be in the history entry
|
||||||
);
|
const state = ref.current.getRootState();
|
||||||
}
|
|
||||||
|
|
||||||
if (ref.current && location.pathname + location.search === '/') {
|
|
||||||
history.replaceState(
|
|
||||||
{ index: history.state?.index ?? 0 },
|
|
||||||
'',
|
|
||||||
getPathFromStateRef.current(
|
|
||||||
ref.current.getRootState(),
|
|
||||||
configRef.current
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsubscribe = ref.current?.addListener('state', () => {
|
|
||||||
const navigation = ref.current;
|
|
||||||
|
|
||||||
if (!navigation) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = navigation.getRootState();
|
|
||||||
const path = getPathFromStateRef.current(state, configRef.current);
|
const path = getPathFromStateRef.current(state, configRef.current);
|
||||||
|
|
||||||
const previousStateLength = previousStateLengthRef.current ?? 1;
|
if (previousStateRef.current === undefined) {
|
||||||
const stateLength = getStateLength(state);
|
previousStateRef.current = state;
|
||||||
|
|
||||||
if (pendingStateMultiUpdateRef.current) {
|
|
||||||
if (location.pathname + location.search === path) {
|
|
||||||
pendingStateMultiUpdateRef.current = false;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
previousStateLengthRef.current = stateLength;
|
history.replace({ path, state });
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
const onStateChange = async () => {
|
||||||
pendingStateUpdateRef.current &&
|
const navigation = ref.current;
|
||||||
location.pathname + location.search === path
|
|
||||||
) {
|
if (!navigation || !enabled) {
|
||||||
pendingStateUpdateRef.current = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let index = history.state?.index ?? 0;
|
const previousState = previousStateRef.current;
|
||||||
|
const state = navigation.getRootState();
|
||||||
|
|
||||||
if (previousStateLength === stateLength) {
|
const pendingPath = pendingPopStatePathRef.current;
|
||||||
// If no new entries were added to history in our navigation state, we want to replaceState
|
const path = getPathFromStateRef.current(state, configRef.current);
|
||||||
if (location.pathname + location.search !== path) {
|
|
||||||
history.replaceState({ index }, '', path);
|
|
||||||
previousHistoryIndexRef.current = index;
|
|
||||||
}
|
|
||||||
} else if (stateLength > previousStateLength) {
|
|
||||||
// If new entries were added, pushState until we have same length
|
|
||||||
// This won't be accurate if multiple entries were added at once, but that's the best we can do
|
|
||||||
for (let i = 0, l = stateLength - previousStateLength; i < l; i++) {
|
|
||||||
index++;
|
|
||||||
history.pushState({ index }, '', path);
|
|
||||||
}
|
|
||||||
|
|
||||||
previousHistoryIndexRef.current = index;
|
previousStateRef.current = state;
|
||||||
} else if (previousStateLength > stateLength) {
|
pendingPopStatePathRef.current = undefined;
|
||||||
const delta = Math.min(
|
|
||||||
previousStateLength - stateLength,
|
// To detect the kind of state change, we need to:
|
||||||
// We need to keep at least one item in the history
|
// - Find the common focused navigation state in previous and current state
|
||||||
// Otherwise we'll exit the page
|
// - If only the route keys changed, compare history/routes.length to check if we go back/forward/replace
|
||||||
previousHistoryIndexRef.current - 1
|
// - If no common focused navigation state found, it's a replace
|
||||||
|
const [previousFocusedState, focusedState] = findMatchingState(
|
||||||
|
previousState,
|
||||||
|
state
|
||||||
);
|
);
|
||||||
|
|
||||||
if (delta > 0) {
|
if (
|
||||||
// We need to set this to ignore the `popstate` event
|
previousFocusedState &&
|
||||||
pendingIndexChangeRef.current = index - delta;
|
focusedState &&
|
||||||
|
// We should only handle push/pop if path changed from what was in last `popstate`
|
||||||
|
// Otherwise it's likely a change triggered by `popstate`
|
||||||
|
path !== pendingPath
|
||||||
|
) {
|
||||||
|
const historyDelta =
|
||||||
|
(focusedState.history
|
||||||
|
? focusedState.history.length
|
||||||
|
: focusedState.routes.length) -
|
||||||
|
(previousFocusedState.history
|
||||||
|
? previousFocusedState.history.length
|
||||||
|
: previousFocusedState.routes.length);
|
||||||
|
|
||||||
// If new entries were removed, go back so that we have same length
|
if (historyDelta > 0) {
|
||||||
history.go(-delta);
|
// If history length is increased, we should pushState
|
||||||
|
// Note that path might not actually change here, for example, drawer open should pushState
|
||||||
|
history.push({ path, state });
|
||||||
|
} else if (historyDelta < 0) {
|
||||||
|
// If history length is decreased, i.e. entries were removed, we want to go back
|
||||||
|
|
||||||
|
const nextIndex = history.backIndex({ path });
|
||||||
|
const currentIndex = history.index;
|
||||||
|
|
||||||
|
if (nextIndex !== -1 && nextIndex < currentIndex) {
|
||||||
|
// An existing entry for this path exists and it's less than current index, go back to that
|
||||||
|
await history.go(nextIndex - currentIndex);
|
||||||
} else {
|
} else {
|
||||||
// We're not going back in history, but the navigation state changed
|
// We couldn't find an existing entry to go back to, so we'll go back by the delta
|
||||||
// The URL probably also changed, so we need to re-sync the URL
|
// This won't be correct if multiple routes were pushed in one go before
|
||||||
if (location.pathname + location.search !== path) {
|
// Usually this shouldn't happen and this is a fallback for that
|
||||||
history.replaceState({ index }, '', path);
|
await history.go(historyDelta);
|
||||||
previousHistoryIndexRef.current = index;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return unsubscribe;
|
// Store the updated state as well as fix the path if incorrect
|
||||||
|
history.replace({ path, state });
|
||||||
|
} else {
|
||||||
|
// If history length is unchanged, we want to replaceState
|
||||||
|
history.replace({ path, state });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no common navigation state was found, assume it's a replace
|
||||||
|
// This would happen if the user did a reset/conditionally changed navigators
|
||||||
|
history.replace({ path, state });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// We debounce onStateChange coz we don't want multiple state changes to be handled at one time
|
||||||
|
// This could happen since `history.go(n)` is asynchronous
|
||||||
|
// If `pushState` or `replaceState` were called before `history.go(n)` completes, it'll mess stuff up
|
||||||
|
return ref.current?.addListener('state', series(onStateChange));
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,6 +3,17 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
## [5.4.2](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/compare/@react-navigation/stack@5.4.1...@react-navigation/stack@5.4.2) (2020-06-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* relatively position float Header if !headerTransparent ([#8285](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/issues/8285)) ([78afbff](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/commit/78afbffe976b14bb60666a2b1230127db0dc24f6))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [5.4.1](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/compare/@react-navigation/stack@5.4.0...@react-navigation/stack@5.4.1) (2020-05-27)
|
## [5.4.1](https://github.com/react-navigation/react-navigation/tree/master/packages/stack/compare/@react-navigation/stack@5.4.0...@react-navigation/stack@5.4.1) (2020-05-27)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@react-navigation/stack",
|
"name": "@react-navigation/stack",
|
||||||
"description": "Stack navigator component for iOS and Android with animated transitions and gestures",
|
"description": "Stack navigator component for iOS and Android with animated transitions and gestures",
|
||||||
"version": "5.4.1",
|
"version": "5.4.2",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"react-native-component",
|
"react-native-component",
|
||||||
"react-component",
|
"react-component",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-native-community/bob": "^0.14.3",
|
"@react-native-community/bob": "^0.14.3",
|
||||||
"@react-native-community/masked-view": "^0.1.10",
|
"@react-native-community/masked-view": "^0.1.10",
|
||||||
"@react-navigation/native": "^5.5.0",
|
"@react-navigation/native": "^5.5.1",
|
||||||
"@types/color": "^3.0.1",
|
"@types/color": "^3.0.1",
|
||||||
"@types/react": "^16.9.34",
|
"@types/react": "^16.9.34",
|
||||||
"@types/react-native": "^0.62.7",
|
"@types/react-native": "^0.62.7",
|
||||||
|
|||||||
@@ -53,8 +53,7 @@ type Props = TransitionPreset & {
|
|||||||
gestureVelocityImpact?: number;
|
gestureVelocityImpact?: number;
|
||||||
mode: StackCardMode;
|
mode: StackCardMode;
|
||||||
headerMode: StackHeaderMode;
|
headerMode: StackHeaderMode;
|
||||||
headerShown?: boolean;
|
hasAbsoluteHeader: boolean;
|
||||||
headerTransparent?: boolean;
|
|
||||||
headerHeight: number;
|
headerHeight: number;
|
||||||
onHeaderHeightChange: (props: {
|
onHeaderHeightChange: (props: {
|
||||||
route: Route<string>;
|
route: Route<string>;
|
||||||
@@ -82,9 +81,8 @@ function CardContainer({
|
|||||||
getFocusedRoute,
|
getFocusedRoute,
|
||||||
mode,
|
mode,
|
||||||
headerMode,
|
headerMode,
|
||||||
headerShown,
|
|
||||||
headerStyleInterpolator,
|
headerStyleInterpolator,
|
||||||
headerTransparent,
|
hasAbsoluteHeader,
|
||||||
headerHeight,
|
headerHeight,
|
||||||
onHeaderHeightChange,
|
onHeaderHeightChange,
|
||||||
index,
|
index,
|
||||||
@@ -187,11 +185,7 @@ function CardContainer({
|
|||||||
importantForAccessibility={focused ? 'auto' : 'no-hide-descendants'}
|
importantForAccessibility={focused ? 'auto' : 'no-hide-descendants'}
|
||||||
pointerEvents={active ? 'box-none' : pointerEvents}
|
pointerEvents={active ? 'box-none' : pointerEvents}
|
||||||
pageOverflowEnabled={headerMode === 'screen' && mode === 'card'}
|
pageOverflowEnabled={headerMode === 'screen' && mode === 'card'}
|
||||||
containerStyle={
|
containerStyle={hasAbsoluteHeader ? { marginTop: headerHeight } : null}
|
||||||
headerMode === 'float' && !headerTransparent && headerShown !== false
|
|
||||||
? { marginTop: headerHeight }
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
contentStyle={[{ backgroundColor: colors.background }, cardStyle]}
|
contentStyle={[{ backgroundColor: colors.background }, cardStyle]}
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -335,6 +335,24 @@ export default class CardStack extends React.Component<Props, State> {
|
|||||||
return state.routes[state.index];
|
return state.routes[state.index];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private doesFloatHeaderNeedAbsolutePositioning = () => {
|
||||||
|
if (this.props.headerMode !== 'float') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.state.scenes.slice(-2).some((scene) => {
|
||||||
|
const { descriptor } = scene;
|
||||||
|
const options = descriptor ? descriptor.options : {};
|
||||||
|
const { headerTransparent, headerShown } = options;
|
||||||
|
|
||||||
|
if (headerTransparent || headerShown === false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
mode,
|
mode,
|
||||||
@@ -363,6 +381,8 @@ export default class CardStack extends React.Component<Props, State> {
|
|||||||
const focusedDescriptor = descriptors[focusedRoute.key];
|
const focusedDescriptor = descriptors[focusedRoute.key];
|
||||||
const focusedOptions = focusedDescriptor ? focusedDescriptor.options : {};
|
const focusedOptions = focusedDescriptor ? focusedDescriptor.options : {};
|
||||||
|
|
||||||
|
const isFloatHeaderAbsolute = this.doesFloatHeaderNeedAbsolutePositioning();
|
||||||
|
|
||||||
let defaultTransitionPreset =
|
let defaultTransitionPreset =
|
||||||
mode === 'modal' ? ModalTransition : DefaultTransition;
|
mode === 'modal' ? ModalTransition : DefaultTransition;
|
||||||
|
|
||||||
@@ -384,8 +404,36 @@ export default class CardStack extends React.Component<Props, State> {
|
|||||||
// For modals, usually we want the screen underneath to be visible, so also disable it there
|
// For modals, usually we want the screen underneath to be visible, so also disable it there
|
||||||
const isScreensEnabled = Platform.OS !== 'ios' && mode !== 'modal';
|
const isScreensEnabled = Platform.OS !== 'ios' && mode !== 'modal';
|
||||||
|
|
||||||
|
let floatingHeader;
|
||||||
|
|
||||||
|
if (headerMode === 'float') {
|
||||||
|
floatingHeader = (
|
||||||
|
<React.Fragment key="header">
|
||||||
|
{renderHeader({
|
||||||
|
mode: 'float',
|
||||||
|
layout,
|
||||||
|
insets: { top, right, bottom, left },
|
||||||
|
scenes,
|
||||||
|
getPreviousRoute,
|
||||||
|
getFocusedRoute: this.getFocusedRoute,
|
||||||
|
onContentHeightChange: this.handleHeaderLayout,
|
||||||
|
gestureDirection:
|
||||||
|
focusedOptions.gestureDirection !== undefined
|
||||||
|
? focusedOptions.gestureDirection
|
||||||
|
: defaultTransitionPreset.gestureDirection,
|
||||||
|
styleInterpolator:
|
||||||
|
focusedOptions.headerStyleInterpolator !== undefined
|
||||||
|
? focusedOptions.headerStyleInterpolator
|
||||||
|
: defaultTransitionPreset.headerStyleInterpolator,
|
||||||
|
style: isFloatHeaderAbsolute ? styles.floating : undefined,
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
|
{isFloatHeaderAbsolute ? null : floatingHeader}
|
||||||
<MaybeScreenContainer
|
<MaybeScreenContainer
|
||||||
enabled={isScreensEnabled}
|
enabled={isScreensEnabled}
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
@@ -522,8 +570,11 @@ export default class CardStack extends React.Component<Props, State> {
|
|||||||
getFocusedRoute={this.getFocusedRoute}
|
getFocusedRoute={this.getFocusedRoute}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
headerMode={headerMode}
|
headerMode={headerMode}
|
||||||
headerShown={headerShown}
|
hasAbsoluteHeader={
|
||||||
headerTransparent={headerTransparent}
|
isFloatHeaderAbsolute &&
|
||||||
|
headerShown !== false &&
|
||||||
|
!headerTransparent
|
||||||
|
}
|
||||||
renderHeader={renderHeader}
|
renderHeader={renderHeader}
|
||||||
renderScene={renderScene}
|
renderScene={renderScene}
|
||||||
onOpenRoute={onOpenRoute}
|
onOpenRoute={onOpenRoute}
|
||||||
@@ -538,26 +589,7 @@ export default class CardStack extends React.Component<Props, State> {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</MaybeScreenContainer>
|
</MaybeScreenContainer>
|
||||||
{headerMode === 'float'
|
{isFloatHeaderAbsolute ? floatingHeader : null}
|
||||||
? renderHeader({
|
|
||||||
mode: 'float',
|
|
||||||
layout,
|
|
||||||
insets: { top, right, bottom, left },
|
|
||||||
scenes,
|
|
||||||
getPreviousRoute,
|
|
||||||
getFocusedRoute: this.getFocusedRoute,
|
|
||||||
onContentHeightChange: this.handleHeaderLayout,
|
|
||||||
gestureDirection:
|
|
||||||
focusedOptions.gestureDirection !== undefined
|
|
||||||
? focusedOptions.gestureDirection
|
|
||||||
: defaultTransitionPreset.gestureDirection,
|
|
||||||
styleInterpolator:
|
|
||||||
focusedOptions.headerStyleInterpolator !== undefined
|
|
||||||
? focusedOptions.headerStyleInterpolator
|
|
||||||
: defaultTransitionPreset.headerStyleInterpolator,
|
|
||||||
style: styles.floating,
|
|
||||||
})
|
|
||||||
: null}
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13506,6 +13506,11 @@ nanoid@^3.1.5:
|
|||||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.5.tgz#56da1bb76b619391fc61625e8b4e4bff309b9942"
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.5.tgz#56da1bb76b619391fc61625e8b4e4bff309b9942"
|
||||||
integrity sha512-77yYm8wPy8igTpUQv9fA0VzEb5Ohxt5naC3zTK1oAb+u1MiyITtx0jpYrYRFfgJlefwJy2SkCaojZvxSYq6toA==
|
integrity sha512-77yYm8wPy8igTpUQv9fA0VzEb5Ohxt5naC3zTK1oAb+u1MiyITtx0jpYrYRFfgJlefwJy2SkCaojZvxSYq6toA==
|
||||||
|
|
||||||
|
nanoid@^3.1.9:
|
||||||
|
version "3.1.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.9.tgz#1f148669c70bb2072dc5af0666e46edb6cd31fb2"
|
||||||
|
integrity sha512-fFiXlFo4Wkuei3i6w9SQI6yuzGRTGi8Z2zZKZpUxv/bQlBi4jtbVPBSNFZHQA9PNjofWqtIa8p+pnsc0kgZrhQ==
|
||||||
|
|
||||||
nanomatch@^1.2.9:
|
nanomatch@^1.2.9:
|
||||||
version "1.2.13"
|
version "1.2.13"
|
||||||
resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
|
resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
|
||||||
|
|||||||
Reference in New Issue
Block a user