Skip to content

Commit

Permalink
feat: allow deep linking to reset state
Browse files Browse the repository at this point in the history
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
  • Loading branch information
satya164 committed Oct 24, 2020
1 parent f51086e commit f6f6d2b
Show file tree
Hide file tree
Showing 9 changed files with 737 additions and 88 deletions.
406 changes: 389 additions & 17 deletions packages/core/src/__tests__/getActionFromState.test.tsx

Large diffs are not rendered by default.

188 changes: 188 additions & 0 deletions packages/core/src/__tests__/index.test.tsx
Expand Up @@ -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);
Expand Down
105 changes: 79 additions & 26 deletions packages/core/src/getActionFromState.tsx
@@ -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;
}

// 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 },
};
state: PartialState<NavigationState>,
options?: Options
): NavigateAction<NavigationState> | undefined {
// Create a normalized configs object which will be easier to use
const normalizedConfig = options ? createNormalizedConfigItem(options) : {};

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 };
Expand All @@ -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;
}, {});
14 changes: 14 additions & 0 deletions packages/core/src/types.tsx
Expand Up @@ -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;
Expand Down

0 comments on commit f6f6d2b

Please sign in to comment.