Skip to content

Commit 5dcaf90

Browse files
authored
refactor: rework history stack integration (facebook#8367)
The PR reworks history integration to better integrate with browser's history stack and supports nested navigation more reliably: - On each navigation, save the navigation in memory and use it to reset the state when user presses back/forward - Improve heuristic to determine if we should do a push, replace or back This closes facebook#8230, closes facebook#8284 and closes facebook#8344
1 parent 2d66ef9 commit 5dcaf90

File tree

8 files changed

+553
-186
lines changed

8 files changed

+553
-186
lines changed

packages/core/src/isArrayEqual.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Compare two arrays with primitive values as the content.
3+
* We need to make sure that both values and order match.
4+
*/
5+
export default function isArrayEqual(a: any[], b: any[]) {
6+
return a.length === b.length && a.every((it, index) => it === b[index]);
7+
}

packages/core/src/useNavigationBuilder.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import useStateGetters from './useStateGetters';
3434
import useOnGetState from './useOnGetState';
3535
import useScheduleUpdate from './useScheduleUpdate';
3636
import useCurrentRender from './useCurrentRender';
37+
import isArrayEqual from './isArrayEqual';
3738

3839
// This is to make TypeScript compiler happy
3940
// eslint-disable-next-line babel/no-unused-expressions
@@ -48,13 +49,6 @@ type NavigatorRoute = {
4849
};
4950
};
5051

51-
/**
52-
* Compare two arrays with primitive values as the content.
53-
* We need to make sure that both values and order match.
54-
*/
55-
const isArrayEqual = (a: any[], b: any[]) =>
56-
a.length === b.length && a.every((it, index) => it === b[index]);
57-
5852
/**
5953
* Extract route config object from React children elements.
6054
*

packages/core/src/useOnGetState.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from 'react';
22
import { NavigationState } from '@react-navigation/routers';
33
import NavigationBuilderContext from './NavigationBuilderContext';
44
import NavigationRouteContext from './NavigationRouteContext';
5+
import isArrayEqual from './isArrayEqual';
56

67
export default function useOnGetState({
78
getStateForRoute,
@@ -16,13 +17,23 @@ export default function useOnGetState({
1617

1718
const getRehydratedState = React.useCallback(() => {
1819
const state = getState();
19-
return {
20-
...state,
21-
routes: state.routes.map((route) => ({
22-
...route,
23-
state: getStateForRoute(route.key),
24-
})),
25-
};
20+
21+
// Avoid returning new route objects if we don't need to
22+
const routes = state.routes.map((route) => {
23+
const childState = getStateForRoute(route.key);
24+
25+
if (route.state === childState) {
26+
return route;
27+
}
28+
29+
return { ...route, state: childState };
30+
});
31+
32+
if (isArrayEqual(state.routes, routes)) {
33+
return state;
34+
}
35+
36+
return { ...state, routes };
2637
}, [getState, getStateForRoute]);
2738

2839
React.useEffect(() => {

packages/native/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333
"clean": "del lib"
3434
},
3535
"dependencies": {
36-
"@react-navigation/core": "^5.9.0"
36+
"@react-navigation/core": "^5.9.0",
37+
"nanoid": "^3.1.9"
3738
},
3839
"devDependencies": {
3940
"@react-native-community/bob": "^0.14.3",
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
const location = new URL('', 'http://example.com');
2+
3+
let listeners: (() => void)[] = [];
4+
let entries = [{ state: null, href: location.href }];
5+
let index = 0;
6+
7+
let currentState: any = null;
8+
9+
const history = {
10+
get state() {
11+
return currentState;
12+
},
13+
14+
pushState(state: any, _: string, path: string) {
15+
Object.assign(location, new URL(path, location.origin));
16+
17+
currentState = state;
18+
entries = entries.slice(0, index + 1);
19+
entries.push({ state, href: location.href });
20+
index = entries.length - 1;
21+
},
22+
23+
replaceState(state: any, _: string, path: string) {
24+
Object.assign(location, new URL(path, location.origin));
25+
26+
currentState = state;
27+
entries[index] = { state, href: location.href };
28+
},
29+
30+
go(n: number) {
31+
setTimeout(() => {
32+
if (
33+
(n > 0 && n < entries.length - index) ||
34+
(n < 0 && Math.abs(n) <= index)
35+
) {
36+
index += n;
37+
Object.assign(location, new URL(entries[index].href));
38+
listeners.forEach((cb) => cb);
39+
}
40+
}, 0);
41+
},
42+
43+
back() {
44+
this.go(-1);
45+
},
46+
47+
forward() {
48+
this.go(1);
49+
},
50+
};
51+
52+
const addEventListener = (type: 'popstate', listener: () => void) => {
53+
if (type === 'popstate') {
54+
listeners.push(listener);
55+
}
56+
};
57+
58+
const removeEventListener = (type: 'popstate', listener: () => void) => {
59+
if (type === 'popstate') {
60+
listeners = listeners.filter((cb) => cb !== listener);
61+
}
62+
};
63+
64+
export default {
65+
location,
66+
history,
67+
addEventListener,
68+
removeEventListener,
69+
};
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import * as React from 'react';
2+
import {
3+
useNavigationBuilder,
4+
createNavigatorFactory,
5+
StackRouter,
6+
TabRouter,
7+
NavigationHelpersContext,
8+
NavigationContainerRef,
9+
} from '@react-navigation/core';
10+
import { act, render } from 'react-native-testing-library';
11+
import NavigationContainer from '../NavigationContainer';
12+
import window from '../__mocks__/window';
13+
14+
// @ts-ignore
15+
global.window = window;
16+
17+
// We want to use the web version of useLinking
18+
jest.mock('../useLinking', () => require('../useLinking.tsx').default);
19+
20+
it('integrates with the history API', () => {
21+
jest.useFakeTimers();
22+
23+
const createStackNavigator = createNavigatorFactory((props: any) => {
24+
const { navigation, state, descriptors } = useNavigationBuilder(
25+
StackRouter,
26+
props
27+
);
28+
29+
return (
30+
<NavigationHelpersContext.Provider value={navigation}>
31+
{state.routes.map((route, i) => (
32+
<div key={route.key} aria-current={state.index === i || undefined}>
33+
{descriptors[route.key].render()}
34+
</div>
35+
))}
36+
</NavigationHelpersContext.Provider>
37+
);
38+
});
39+
40+
const createTabNavigator = createNavigatorFactory((props: any) => {
41+
const { navigation, state, descriptors } = useNavigationBuilder(
42+
TabRouter,
43+
props
44+
);
45+
46+
return (
47+
<NavigationHelpersContext.Provider value={navigation}>
48+
{state.routes.map((route, i) => (
49+
<div key={route.key} aria-current={state.index === i || undefined}>
50+
{descriptors[route.key].render()}
51+
</div>
52+
))}
53+
</NavigationHelpersContext.Provider>
54+
);
55+
});
56+
57+
const Stack = createStackNavigator();
58+
const Tab = createTabNavigator();
59+
60+
const TestScreen = ({ route }: any): any =>
61+
`${route.name} ${JSON.stringify(route.params)}`;
62+
63+
const linking = {
64+
prefixes: [],
65+
config: {
66+
Home: {
67+
path: '',
68+
initialRouteName: 'Feed',
69+
screens: {
70+
Profile: ':user',
71+
Settings: 'edit',
72+
Updates: 'updates',
73+
Feed: 'feed',
74+
},
75+
},
76+
Chat: 'chat',
77+
},
78+
};
79+
80+
const navigation = React.createRef<NavigationContainerRef>();
81+
82+
render(
83+
<NavigationContainer ref={navigation} linking={linking}>
84+
<Tab.Navigator>
85+
<Tab.Screen name="Home">
86+
{() => (
87+
<Stack.Navigator initialRouteName="Feed">
88+
<Stack.Screen name="Profile" component={TestScreen} />
89+
<Stack.Screen name="Settings" component={TestScreen} />
90+
<Stack.Screen name="Feed" component={TestScreen} />
91+
<Stack.Screen name="Updates" component={TestScreen} />
92+
</Stack.Navigator>
93+
)}
94+
</Tab.Screen>
95+
<Tab.Screen name="Chat" component={TestScreen} />
96+
</Tab.Navigator>
97+
</NavigationContainer>
98+
);
99+
100+
expect(window.location.pathname).toBe('/feed');
101+
102+
act(() => navigation.current?.navigate('Profile', { user: 'jane' }));
103+
104+
expect(window.location.pathname).toBe('/jane');
105+
106+
act(() => navigation.current?.navigate('Updates'));
107+
108+
expect(window.location.pathname).toBe('/updates');
109+
110+
act(() => navigation.current?.goBack());
111+
112+
jest.runAllTimers();
113+
114+
expect(window.location.pathname).toBe('/jane');
115+
116+
act(() => {
117+
window.history.back();
118+
jest.runAllTimers();
119+
});
120+
121+
expect(window.location.pathname).toBe('/feed');
122+
123+
act(() => {
124+
window.history.forward();
125+
jest.runAllTimers();
126+
});
127+
128+
expect(window.location.pathname).toBe('/jane');
129+
130+
act(() => navigation.current?.navigate('Settings'));
131+
132+
expect(window.location.pathname).toBe('/edit');
133+
134+
act(() => {
135+
window.history.go(-2);
136+
jest.runAllTimers();
137+
});
138+
139+
expect(window.location.pathname).toBe('/feed');
140+
141+
act(() => navigation.current?.navigate('Settings'));
142+
act(() => navigation.current?.navigate('Chat'));
143+
144+
expect(window.location.pathname).toBe('/chat');
145+
146+
act(() => navigation.current?.navigate('Home'));
147+
148+
expect(window.location.pathname).toBe('/edit');
149+
});

0 commit comments

Comments
 (0)