feat: allow deep linking to reset state (#8973)

Currently when we receive a deep link after the app is rendered, it always results in a `navigate` action. While it's ok with the default configuration, it may result in incorrect behaviour when a custom `getStateForPath` function is provided and it returns a routes array different than the initial route and new route pair.

The commit changes 2 things:

1. Add ability to reset state via params of `navigate` by specifying a `state` property instead of `screen`
2. Update `getStateForAction` to return an action for reset when necessary according to the deep linking configuration

Closes #8952
This commit is contained in:
Satyajit Sahoo
2020-10-24 15:27:06 +02:00
committed by GitHub
parent f51086edea
commit 7f3b27a9ec
9 changed files with 737 additions and 88 deletions

View File

@@ -43,32 +43,207 @@ it('gets navigate action from state', () => {
},
type: 'NAVIGATE',
});
});
expect(
getActionFromState({
routes: [
{
name: 'foo',
it('gets navigate action from state with 2 screens', () => {
const state = {
routes: [
{
name: 'foo',
state: {
routes: [
{
name: 'bar',
state: {
routes: [
{
name: 'qux',
params: { author: 'jane' },
},
{ name: 'quz' },
],
},
},
],
},
},
],
};
expect(getActionFromState(state)).toEqual({
payload: {
name: 'foo',
params: {
screen: 'bar',
initial: true,
params: {
state: {
routes: [
{
name: 'bar',
state: {
routes: [
{
name: 'qux',
params: { author: 'jane' },
},
{ name: 'quz' },
],
name: 'qux',
params: {
author: 'jane',
},
},
{ name: 'quz' },
],
},
},
],
})
).toEqual({
},
},
type: 'NAVIGATE',
});
});
it('gets navigate action from state with more than 2 screens', () => {
const state = {
routes: [
{
name: 'foo',
state: {
routes: [
{
name: 'bar',
state: {
routes: [
{
name: 'qux',
params: { author: 'jane' },
},
{ name: 'quz' },
{ name: 'qua' },
],
},
},
],
},
},
],
};
expect(getActionFromState(state)).toEqual({
payload: {
name: 'foo',
params: {
screen: 'bar',
initial: true,
params: {
state: {
routes: [
{
name: 'qux',
params: {
author: 'jane',
},
},
{ name: 'quz' },
{ name: 'qua' },
],
},
},
},
},
type: 'NAVIGATE',
});
});
it('gets navigate action from state with config', () => {
const state = {
routes: [
{
name: 'foo',
state: {
routes: [
{
name: 'bar',
params: { answer: 42 },
state: {
routes: [
{
name: 'qux',
params: { author: 'jane' },
},
],
},
},
],
},
},
],
};
const config = {
screens: {
foo: {
initialRouteName: 'bar',
screens: {
bar: {
initialRouteName: 'qux',
},
},
},
},
};
expect(getActionFromState(state, config)).toEqual({
payload: {
name: 'foo',
params: {
params: {
answer: 42,
params: {
author: 'jane',
},
screen: 'qux',
initial: true,
},
screen: 'bar',
initial: true,
},
},
type: 'NAVIGATE',
});
});
it('gets navigate action from state with 2 screens including initial route and with config', () => {
const state = {
routes: [
{
name: 'foo',
state: {
routes: [
{
name: 'bar',
state: {
routes: [
{
name: 'qux',
params: { author: 'jane' },
},
{ name: 'quz' },
],
},
},
],
},
},
],
};
const config = {
screens: {
foo: {
initialRouteName: 'bar',
screens: {
bar: {
initialRouteName: 'qux',
},
},
},
},
};
expect(getActionFromState(state, config)).toEqual({
payload: {
name: 'foo',
params: {
@@ -84,6 +259,203 @@ it('gets navigate action from state', () => {
});
});
it('gets navigate action from state with 2 screens without initial route and with config', () => {
const state = {
routes: [
{
name: 'foo',
state: {
routes: [
{
name: 'bar',
state: {
routes: [
{
name: 'qux',
params: { author: 'jane' },
},
{ name: 'quz' },
],
},
},
],
},
},
],
};
const config = {
screens: {
foo: {
initialRouteName: 'bar',
screens: {
bar: {
initialRouteName: 'quz',
},
},
},
},
};
expect(getActionFromState(state, config)).toEqual({
payload: {
name: 'foo',
params: {
initial: true,
screen: 'bar',
params: {
state: {
routes: [
{
name: 'qux',
params: {
author: 'jane',
},
},
{ name: 'quz' },
],
},
},
},
},
type: 'NAVIGATE',
});
});
it('gets navigate action from state with 2 screens including route with key and with config', () => {
const state = {
routes: [
{
name: 'foo',
state: {
routes: [
{
name: 'bar',
state: {
routes: [
{
key: 'test',
name: 'qux',
params: { author: 'jane' },
},
{ name: 'quz' },
],
},
},
],
},
},
],
};
const config = {
screens: {
foo: {
initialRouteName: 'bar',
screens: {
bar: {
initialRouteName: 'qux',
},
},
},
},
};
expect(getActionFromState(state, config)).toEqual({
payload: {
name: 'foo',
params: {
initial: true,
screen: 'bar',
params: {
state: {
routes: [
{
key: 'test',
name: 'qux',
params: {
author: 'jane',
},
},
{ name: 'quz' },
],
},
},
},
},
type: 'NAVIGATE',
});
});
it('gets navigate action from state with more than 2 screens and with config', () => {
const state = {
routes: [
{
name: 'foo',
state: {
routes: [
{
name: 'bar',
state: {
routes: [
{
name: 'qux',
params: { author: 'jane' },
},
{ name: 'quz' },
{ name: 'qua' },
],
},
},
],
},
},
],
};
const config = {
screens: {
foo: {
initialRouteName: 'bar',
screens: {
bar: {
initialRouteName: 'qux',
},
},
},
},
};
expect(getActionFromState(state, config)).toEqual({
payload: {
name: 'foo',
params: {
initial: true,
screen: 'bar',
params: {
state: {
routes: [
{
name: 'qux',
params: {
author: 'jane',
},
},
{ name: 'quz' },
{ name: 'qua' },
],
},
},
},
},
type: 'NAVIGATE',
});
});
it("doesn't return action if no routes are provided'", () => {
expect(getActionFromState({ routes: [] })).toBe(undefined);
});
it('gets reset action from state', () => {
const state = {
routes: [

View File

@@ -1093,6 +1093,194 @@ it('navigates to nested child in a navigator with initial: false', () => {
});
});
it('resets state of a nested child in a navigator', () => {
const TestNavigator = (props: any): any => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
return descriptors[state.routes[state.index].key].render();
};
const TestComponent = ({ route }: any): any =>
`[${route.name}, ${JSON.stringify(route.params)}]`;
const onStateChange = jest.fn();
const navigation = React.createRef<NavigationContainerRef>();
const first = render(
<BaseNavigationContainer ref={navigation} onStateChange={onStateChange}>
<TestNavigator>
<Screen name="foo">
{() => (
<TestNavigator>
<Screen name="foo-a" component={TestComponent} />
<Screen name="foo-b" component={TestComponent} />
</TestNavigator>
)}
</Screen>
<Screen name="bar">
{() => (
<TestNavigator initialRouteName="bar-a">
<Screen name="bar-a" component={TestComponent} />
<Screen
name="bar-b"
component={TestComponent}
initialParams={{ some: 'stuff' }}
/>
</TestNavigator>
)}
</Screen>
</TestNavigator>
</BaseNavigationContainer>
);
expect(first).toMatchInlineSnapshot(`"[foo-a, undefined]"`);
expect(navigation.current?.getRootState()).toEqual({
index: 0,
key: '0',
routeNames: ['foo', 'bar'],
routes: [
{
key: 'foo',
name: 'foo',
state: {
index: 0,
key: '1',
routeNames: ['foo-a', 'foo-b'],
routes: [
{
key: 'foo-a',
name: 'foo-a',
},
{
key: 'foo-b',
name: 'foo-b',
},
],
stale: false,
type: 'test',
},
},
{ key: 'bar', name: 'bar' },
],
stale: false,
type: 'test',
});
act(() =>
navigation.current?.navigate('bar', {
state: {
routes: [{ name: 'bar-a' }, { name: 'bar-b' }],
},
})
);
expect(first).toMatchInlineSnapshot(`"[bar-a, undefined]"`);
expect(navigation.current?.getRootState()).toEqual({
index: 1,
key: '0',
routeNames: ['foo', 'bar'],
routes: [
{ key: 'foo', name: 'foo' },
{
key: 'bar',
name: 'bar',
params: {
state: {
routes: [{ name: 'bar-a' }, { name: 'bar-b' }],
},
},
state: {
index: 0,
key: '4',
routeNames: ['bar-a', 'bar-b'],
routes: [
{
key: 'bar-a-2',
name: 'bar-a',
},
{
key: 'bar-b-3',
name: 'bar-b',
params: { some: 'stuff' },
},
],
stale: false,
type: 'test',
},
},
],
stale: false,
type: 'test',
});
act(() =>
navigation.current?.navigate('bar', {
state: {
index: 2,
routes: [
{ key: '37', name: 'bar-b' },
{ name: 'bar-b' },
{ name: 'bar-a', params: { test: 18 } },
],
},
})
);
expect(first).toMatchInlineSnapshot(`"[bar-a, {\\"test\\":18}]"`);
expect(navigation.current?.getRootState()).toEqual({
index: 1,
key: '0',
routeNames: ['foo', 'bar'],
routes: [
{ key: 'foo', name: 'foo' },
{
key: 'bar',
name: 'bar',
params: {
state: {
index: 2,
routes: [
{ key: '37', name: 'bar-b' },
{ name: 'bar-b' },
{ name: 'bar-a', params: { test: 18 } },
],
},
},
state: {
index: 2,
key: '7',
routeNames: ['bar-a', 'bar-b'],
routes: [
{
key: '37',
name: 'bar-b',
params: { some: 'stuff' },
},
{
key: 'bar-b-5',
name: 'bar-b',
params: { some: 'stuff' },
},
{
key: 'bar-a-6',
name: 'bar-a',
params: { test: 18 },
},
],
stale: false,
type: 'test',
},
},
],
stale: false,
type: 'test',
});
});
it('gives access to internal state', () => {
const TestNavigator = (props: any): any => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);

View File

@@ -1,43 +1,65 @@
import type { PartialState, NavigationState } from '@react-navigation/routers';
import type {
Route,
PartialRoute,
NavigationState,
PartialState,
} from '@react-navigation/routers';
import type { PathConfig, PathConfigMap, NestedNavigateParams } from './types';
type NavigateParams = {
screen?: string;
params?: NavigateParams;
initial?: boolean;
type ConfigItem = {
initialRouteName?: string;
screens?: Record<string, ConfigItem>;
};
type NavigateAction = {
type Options = { initialRouteName?: string; screens: PathConfigMap };
type NavigateAction<State extends NavigationState> = {
type: 'NAVIGATE';
payload: { name: string; params: NavigateParams };
payload: {
name: string;
params?: NestedNavigateParams<State>;
};
};
export default function getActionFromState(
state: PartialState<NavigationState>
): NavigateAction | undefined {
if (state.routes.length === 0) {
return undefined;
}
state: PartialState<NavigationState>,
options?: Options
): NavigateAction<NavigationState> | undefined {
// Create a normalized configs object which will be easier to use
const normalizedConfig = options ? createNormalizedConfigItem(options) : {};
// Try to construct payload for a `NAVIGATE` action from the state
// This lets us preserve the navigation state and not lose it
let route = state.routes[state.routes.length - 1];
let payload: { name: string; params: NavigateParams } = {
name: route.name,
params: { ...route.params },
};
let current = route.state;
let params = payload.params;
let payload;
let current: PartialState<NavigationState> | undefined = state;
let config: ConfigItem | undefined = normalizedConfig;
let params: NestedNavigateParams<NavigationState> = {};
while (current) {
if (current.routes.length === 0) {
return undefined;
}
route = current.routes[current.routes.length - 1];
params.initial = current.routes.length === 1;
params.screen = route.name;
const route: Route<string> | PartialRoute<Route<string>> =
current.routes[current.routes.length - 1];
if (current.routes.length === 1) {
params.initial = true;
params.screen = route.name;
params.state = undefined; // Explicitly set to override existing value when merging params
} else if (
current.routes.length === 2 &&
current.routes[0].key === undefined &&
current.routes[0].name === config?.initialRouteName
) {
params.initial = false;
params.screen = route.name;
params.state = undefined;
} else {
params.initial = undefined;
params.screen = undefined;
params.params = undefined;
params.state = current;
break;
}
if (route.state) {
params.params = { ...route.params };
@@ -47,10 +69,41 @@ export default function getActionFromState(
}
current = route.state;
config = config?.screens?.[route.name];
if (!payload) {
payload = {
name: route.name,
params,
};
}
}
if (!payload) {
return;
}
// Try to construct payload for a `NAVIGATE` action from the state
// This lets us preserve the navigation state and not lose it
return {
type: 'NAVIGATE',
payload,
};
}
const createNormalizedConfigItem = (config: PathConfig | string) =>
typeof config === 'object' && config != null
? {
initialRouteName: config.initialRouteName,
screens:
config.screens != null
? createNormalizedConfigs(config.screens)
: undefined,
}
: {};
const createNormalizedConfigs = (options: PathConfigMap) =>
Object.entries(options).reduce<Record<string, ConfigItem>>((acc, [k, v]) => {
acc[k] = createNormalizedConfigItem(v);
return acc;
}, {});

View File

@@ -506,6 +506,20 @@ export type TypedNavigator<
) => null;
};
export type NestedNavigateParams<State extends NavigationState> =
| {
screen?: string;
params?: object;
initial?: boolean;
state?: never;
}
| {
screen?: never;
params?: never;
initial?: never;
state?: PartialState<State> | State;
};
export type PathConfig = {
path?: string;
exact?: boolean;

View File

@@ -23,30 +23,27 @@ import useFocusEvents from './useFocusEvents';
import useOnRouteFocus from './useOnRouteFocus';
import useChildListeners from './useChildListeners';
import useFocusedListenersChildrenAdapter from './useFocusedListenersChildrenAdapter';
import useKeyedChildListeners from './useKeyedChildListeners';
import useOnGetState from './useOnGetState';
import useScheduleUpdate from './useScheduleUpdate';
import useCurrentRender from './useCurrentRender';
import isArrayEqual from './isArrayEqual';
import {
DefaultNavigatorOptions,
RouteConfig,
PrivateValueStore,
EventMapBase,
EventMapCore,
NestedNavigateParams,
} from './types';
import useKeyedChildListeners from './useKeyedChildListeners';
import useOnGetState from './useOnGetState';
import useScheduleUpdate from './useScheduleUpdate';
import useCurrentRender from './useCurrentRender';
import isArrayEqual from './isArrayEqual';
// This is to make TypeScript compiler happy
// eslint-disable-next-line babel/no-unused-expressions
PrivateValueStore;
type NavigatorRoute = {
type NavigatorRoute<State extends NavigationState> = {
key: string;
params?: {
screen?: string;
params?: object;
initial?: boolean;
};
params?: NestedNavigateParams<State>;
};
/**
@@ -192,20 +189,15 @@ export default function useNavigationBuilder<
const navigatorKey = useRegisterNavigator();
const route = React.useContext(NavigationRouteContext) as
| NavigatorRoute
| NavigatorRoute<State>
| undefined;
const previousNestedParamsRef = React.useRef(route?.params);
React.useEffect(() => {
previousNestedParamsRef.current = route?.params;
}, [route]);
const { children, ...rest } = options;
const { current: router } = React.useRef<Router<State, any>>(
createRouter({
...((rest as unknown) as RouterOptions),
...(route?.params &&
route.params.state == null &&
route.params.initial !== false &&
typeof route.params.screen === 'string'
? { initialRouteName: route.params.screen }
@@ -240,7 +232,9 @@ export default function useNavigationBuilder<
(acc, curr) => {
const { initialParams } = screens[curr];
const initialParamsFromParams =
route?.params?.initial !== false && route?.params?.screen === curr
route?.params?.state == null &&
route?.params?.initial !== false &&
route?.params?.screen === curr
? route.params.params
: undefined;
@@ -288,7 +282,10 @@ export default function useNavigationBuilder<
// We also need to re-initialize it if the state passed from parent was changed (maybe due to reset)
// Otherwise assume that the state was provided as initial state
// So we need to rehydrate it to make it usable
if (currentState === undefined || !isStateValid(currentState)) {
if (
(currentState === undefined || !isStateValid(currentState)) &&
route?.params?.state == null
) {
return [
router.getInitialState({
routeNames,
@@ -298,10 +295,13 @@ export default function useNavigationBuilder<
];
} else {
return [
router.getRehydratedState(currentState as PartialState<State>, {
routeNames,
routeParamList,
}),
router.getRehydratedState(
route?.params?.state ?? (currentState as PartialState<State>),
{
routeNames,
routeParamList,
}
),
false,
];
}
@@ -332,21 +332,41 @@ export default function useNavigationBuilder<
});
}
if (
typeof route?.params?.screen === 'string' &&
(route.params !== previousNestedParamsRef.current ||
(route.params.initial === false && isFirstStateInitialization))
) {
// If the route was updated with new name and/or params, we should navigate there
const previousNestedParamsRef = React.useRef(route?.params);
React.useEffect(() => {
previousNestedParamsRef.current = route?.params;
}, [route?.params]);
if (route?.params) {
const previousParams = previousNestedParamsRef.current;
let action: CommonActions.Action | undefined;
if (
typeof route.params.state === 'object' &&
route.params.state != null &&
route.params.state !== previousParams?.state
) {
// If the route was updated with new state, we should reset to it
action = CommonActions.reset(route.params.state);
} else if (
typeof route.params.screen === 'string' &&
((route.params.initial === false && isFirstStateInitialization) ||
route.params.screen !== previousParams?.screen ||
route.params.params !== previousParams?.params)
) {
// If the route was updated with new screen name and/or params, we should navigate there
action = CommonActions.navigate(route.params.screen, route.params.params);
}
// The update should be limited to current navigator only, so we call the router manually
const updatedState = router.getStateForAction(
nextState,
CommonActions.navigate(route.params.screen, route.params.params),
{
routeNames,
routeParamList,
}
);
const updatedState = action
? router.getStateForAction(nextState, action, {
routeNames,
routeParamList,
})
: null;
nextState =
updatedState !== null

View File

@@ -37,7 +37,7 @@ export default function useLinkTo() {
root = current;
}
const action = getActionFromState(state);
const action = getActionFromState(state, options?.config);
if (action !== undefined) {
root.dispatch(action);

View File

@@ -111,7 +111,7 @@ export default function useLinking(
const state = getStateFromPathRef.current(path, configRef.current);
if (state) {
const action = getActionFromState(state);
const action = getActionFromState(state, configRef.current);
if (action !== undefined) {
navigation.dispatch(action);

View File

@@ -400,7 +400,7 @@ export default function useLinking(
// We should only dispatch an action when going forward
// Otherwise the action will likely add items to history, which would mess things up
if (state && index > previousIndex) {
const action = getActionFromState(state);
const action = getActionFromState(state, configRef.current);
if (action !== undefined) {
navigation.dispatch(action);

View File

@@ -50,16 +50,18 @@ export type InitialState = Readonly<
}
>;
export type PartialRoute<R extends Route<string>> = Omit<R, 'key'> & {
key?: string;
state?: PartialState<NavigationState>;
};
export type PartialState<State extends NavigationState> = Partial<
Omit<State, 'stale' | 'type' | 'key' | 'routes' | 'routeNames'>
> &
Readonly<{
stale?: true;
type?: string;
routes: (Omit<Route<string>, 'key'> & {
key?: string;
state?: InitialState;
})[];
routes: PartialRoute<Route<string>>[];
}>;
export type Route<