feat: add hook for deep link support

This commit is contained in:
satyajit.happy
2019-08-18 16:14:45 +05:30
committed by Satyajit Sahoo
parent f0b80ce0f6
commit 35987ae369
16 changed files with 278 additions and 77 deletions

View File

@@ -1,4 +1,4 @@
import { NavigationState } from './types';
import { NavigationState, PartialState } from './types';
export type Action =
| {
@@ -22,7 +22,7 @@ export type Action =
}
| {
type: 'RESET';
payload: Partial<NavigationState>;
payload: PartialState<NavigationState>;
source?: string;
target?: string;
}
@@ -64,7 +64,7 @@ export function replace(name: string, params?: object): Action {
return { type: 'REPLACE', payload: { name, params } };
}
export function reset(state: Partial<NavigationState>): Action {
export function reset(state: PartialState<NavigationState>): Action {
return { type: 'RESET', payload: state };
}

View File

@@ -25,7 +25,7 @@ const NavigationBuilderContext = React.createContext<{
addActionListener?: (listener: ChildActionListener) => void;
addFocusedListener?: (listener: FocusedNavigationListener) => void;
onRouteFocus?: (key: string) => void;
trackAction: (key: string, action: NavigationAction) => void;
trackAction: (action: NavigationAction) => void;
}>({
trackAction: () => undefined,
});

View File

@@ -10,21 +10,18 @@ import {
NavigationState,
InitialState,
PartialState,
ParamListBase,
NavigationHelpers,
NavigationAction,
NavigationContainerRef,
} from './types';
type State = NavigationState | PartialState<NavigationState> | undefined;
type Props = {
initialState?: InitialState;
onStateChange?: (
state: NavigationState | PartialState<NavigationState> | undefined
) => void;
onStateChange?: (state: State) => void;
children: React.ReactNode;
};
type State = NavigationState | PartialState<NavigationState> | undefined;
const MISSING_CONTEXT_ERROR =
"We couldn't find a navigation context. Have you wrapped your app with 'NavigationContainer'?";
@@ -88,7 +85,7 @@ const getPartialState = (
*/
const Container = React.forwardRef(function NavigationContainer(
{ initialState, onStateChange, children }: Props,
ref: React.Ref<NavigationHelpers<ParamListBase>>
ref: React.Ref<NavigationContainerRef>
) {
const [state, setNavigationState] = React.useState<State>(() =>
getPartialState(initialState)
@@ -126,6 +123,10 @@ const Container = React.forwardRef(function NavigationContainer(
);
return acc;
}, {}),
resetRoot: (state: PartialState<NavigationState> | NavigationState) => {
trackAction('@@RESET_ROOT');
setNavigationState(state);
},
dispatch,
canGoBack,
}));

View File

@@ -126,6 +126,7 @@ it('resets state to new state with RESET', () => {
it('ignores key and routeNames when resetting with RESET', () => {
const result = BaseRouter.getStateForAction(
STATE,
// @ts-ignore
BaseActions.reset({ index: 2, key: 'foo', routeNames: ['test'] })
);

View File

@@ -10,8 +10,7 @@ import {
DefaultRouterOptions,
NavigationState,
Router,
NavigationHelpers,
ParamListBase,
NavigationContainerRef,
} from '../types';
it('throws when getState is accessed without a container', () => {
@@ -173,7 +172,7 @@ it('handle dispatching with ref', () => {
);
};
const ref = React.createRef<NavigationHelpers<ParamListBase>>();
const ref = React.createRef<NavigationContainerRef>();
const onStateChange = jest.fn();
@@ -226,7 +225,7 @@ it('handle dispatching with ref', () => {
render(element).update(element);
act(() => {
if (ref.current !== null) {
if (ref.current != null) {
ref.current.dispatch({ type: 'REVERSE' });
}
});
@@ -253,3 +252,73 @@ it('handle dispatching with ref', () => {
],
});
});
it('handle resetting state with ref', () => {
const TestNavigator = (props: any) => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
return (
<React.Fragment>
{state.routes.map(route => descriptors[route.key].render())}
</React.Fragment>
);
};
const ref = React.createRef<NavigationContainerRef>();
const onStateChange = jest.fn();
const element = (
<NavigationContainer ref={ref} onStateChange={onStateChange}>
<TestNavigator>
<Screen name="foo">{() => null}</Screen>
<Screen name="foo2">
{() => (
<TestNavigator>
<Screen name="qux" component={() => null} />
<Screen name="lex" component={() => null} />
</TestNavigator>
)}
</Screen>
<Screen name="bar">{() => null}</Screen>
<Screen name="baz">
{() => (
<TestNavigator>
<Screen name="qux" component={() => null} />
<Screen name="lex" component={() => null} />
</TestNavigator>
)}
</Screen>
</TestNavigator>
</NavigationContainer>
);
render(element).update(element);
const state = {
stale: true,
index: 1,
routes: [
{
key: 'baz',
name: 'baz',
state: {
index: 0,
key: '4',
routeNames: ['qux', 'lex'],
routes: [{ key: 'qux', name: 'qux' }, { key: 'lex', name: 'lex' }],
},
},
{ key: 'bar', name: 'bar' },
],
};
act(() => {
if (ref.current != null) {
ref.current.resetRoot(state);
}
});
expect(onStateChange).toBeCalledTimes(1);
expect(onStateChange).lastCalledWith(state);
});

View File

@@ -1,4 +1,4 @@
import { InitialState } from './types';
import { NavigationState, PartialState } from './types';
/**
* Utility to parse a path string to initial state object accepted by the container.
@@ -8,17 +8,17 @@ import { InitialState } from './types';
*/
export default function getStateFromPath(
path: string
): InitialState | undefined {
): PartialState<NavigationState> | undefined {
const parts = path.split('?');
const segments = parts[0].split('/').filter(Boolean);
const query = parts[1] ? parts[1].split('&') : undefined;
let result: InitialState | undefined;
let current: InitialState | undefined;
let result: PartialState<NavigationState> | undefined;
let current: PartialState<NavigationState> | undefined;
while (segments.length) {
const state = {
stale: true as true,
stale: true,
routes: [{ name: decodeURIComponent(segments[0]) }],
};

View File

@@ -37,7 +37,7 @@ export type InitialState = Partial<
export type PartialState<State extends NavigationState> = Partial<
Omit<State, 'stale' | 'key' | 'routes' | 'routeNames'>
> & {
stale: boolean;
stale?: boolean;
routes: Array<
Omit<Route<string>, 'key'> & { key?: string; state?: InitialState }
>;
@@ -304,7 +304,7 @@ type NavigationHelpersCommon<
*
* @param state Navigation state object.
*/
reset(state: Partial<State>): void;
reset(state: PartialState<State> | State): void;
/**
* Go back to the previous route in history.
@@ -486,6 +486,18 @@ export type RouteConfig<
children: (props: any) => React.ReactNode;
});
export type NavigationContainerRef =
| NavigationHelpers<ParamListBase> & {
/**
* Reset the navigation state of the root navigator to the provided state.
*
* @param state Navigation state object.
*/
resetRoot(state: PartialState<NavigationState> | NavigationState): void;
}
| undefined
| null;
export type TypedNavigator<
ParamList extends ParamListBase,
ScreenOptions extends object,

View File

@@ -43,9 +43,7 @@ export default function useDevTools({ name, reset, state }: Options) {
const devTools = devToolsRef.current;
const lastStateRef = React.useRef<State>(state);
const actions = React.useRef<
Array<{ key: string; action: NavigationAction }>
>([]);
const actions = React.useRef<Array<NavigationAction | string>>([]);
React.useEffect(() => {
devTools && devTools.init(lastStateRef.current);
@@ -84,12 +82,12 @@ export default function useDevTools({ name, reset, state }: Options) {
);
const trackAction = React.useCallback(
(key: string, action: NavigationAction) => {
(action: NavigationAction | string) => {
if (!devTools) {
return;
}
actions.current.push({ key, action });
actions.current.push(action);
},
[devTools]
);

View File

@@ -129,27 +129,41 @@ export default function useNavigationBuilder<
performTransaction,
} = React.useContext(NavigationStateContext);
const [initialState] = React.useState(() =>
const previousStateRef = React.useRef<
NavigationState | PartialState<NavigationState> | undefined
>();
const initializedStateRef = React.useRef<State>();
if (
initializedStateRef.current === undefined ||
currentState !== previousStateRef.current
) {
// If the current state isn't initialized on first render, we initialize it
// 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
currentState === undefined
? router.getInitialState({
routeNames,
routeParamList,
})
: router.getRehydratedState(currentState as PartialState<State>, {
routeNames,
routeParamList,
})
);
initializedStateRef.current =
currentState === undefined
? router.getInitialState({
routeNames,
routeParamList,
})
: router.getRehydratedState(currentState as PartialState<State>, {
routeNames,
routeParamList,
});
}
React.useEffect(() => {
previousStateRef.current = currentState;
}, [currentState]);
let state =
// If the state isn't initialized, or stale, use the state we initialized instead
// The state won't update until there's a change needed in the state we have initalized locally
// So it'll be `undefined` or stale untill the first navigation event happens
currentState === undefined || currentState.stale
? initialState
? (initializedStateRef.current as State)
: (currentState as State);
if (!isArrayEqual(state.routeNames, routeNames)) {
@@ -188,9 +202,9 @@ export default function useNavigationBuilder<
const currentState = getCurrentState();
return currentState === undefined || currentState.stale
? initialState
? (initializedStateRef.current as State)
: (currentState as State);
}, [getCurrentState, initialState]);
}, [getCurrentState]);
const emitter = useEventEmitter();

View File

@@ -61,7 +61,7 @@ export default function useOnAction({
result = result === null && action.target === state.key ? state : result;
if (result !== null) {
trackAction(state.key, action);
trackAction(action);
if (state !== result) {
setState(result);

View File

@@ -1,14 +1,15 @@
import * as React from 'react';
import { ScrollView, AsyncStorage, YellowBox } from 'react-native';
import { Linking } from 'expo';
import { Appbar, List } from 'react-native-paper';
import { Asset } from 'expo-asset';
import {
NavigationContainer,
InitialState,
NavigationHelpers,
ParamListBase,
getStateFromPath,
NavigationContainerRef,
} from '@navigation-ex/core';
import { useNativeIntegration } from '@navigation-ex/native';
import { useBackButton, useLinking } from '@navigation-ex/native';
import {
createDrawerNavigator,
DrawerNavigationProp,
@@ -57,9 +58,34 @@ const PERSISTENCE_KEY = 'NAVIGATION_STATE';
Asset.loadAsync(StackAssets);
export default function App() {
const containerRef = React.useRef<NavigationHelpers<ParamListBase>>(null);
const containerRef = React.useRef<NavigationContainerRef>();
useNativeIntegration(containerRef);
useBackButton(containerRef);
// To test deep linking on, run the following in the Terminal:
// Android: adb shell am start -a android.intent.action.VIEW -d "exp://127.0.0.1:19000/--/simple-stack"
// iOS: xcrun simctl openurl booted exp://127.0.0.1:19000/--/simple-stack
// The first segment of the link is the the scheme + host (returned by `Linking.makeUrl`)
const { getInitialState } = useLinking(containerRef, {
prefixes: [Linking.makeUrl('/')],
getStateFromPath: path => {
const state = getStateFromPath(path);
return {
stale: true,
routes: [
{
name: 'root',
state: {
...state,
stale: true,
routes: [{ name: 'home' }, ...(state ? state.routes : [])],
},
},
],
};
},
});
const [isReady, setIsReady] = React.useState(false);
const [initialState, setInitialState] = React.useState<
@@ -67,21 +93,25 @@ export default function App() {
>();
React.useEffect(() => {
AsyncStorage.getItem(PERSISTENCE_KEY).then(
data => {
try {
const result = JSON.parse(data || '');
const restoreState = async () => {
try {
let state = await getInitialState();
if (result) {
setInitialState(result);
}
} finally {
setIsReady(true);
if (state === undefined) {
const savedState = await AsyncStorage.getItem(PERSISTENCE_KEY);
state = savedState ? JSON.parse(savedState) : undefined;
}
},
() => setIsReady(true)
);
}, []);
if (state !== undefined) {
setInitialState(state);
}
} finally {
setIsReady(true);
}
};
restoreState();
}, [getInitialState]);
if (!isReady) {
return null;

View File

@@ -1,2 +1,2 @@
export { default as useBackButton } from './useBackButton';
export { default as useNativeIntegration } from './useNativeIntegration';
export { default as useLinking } from './useLinking';

View File

@@ -1,20 +1,20 @@
import * as React from 'react';
import { NavigationHelpers, ParamListBase } from '@navigation-ex/core';
import { NavigationContainerRef } from '@navigation-ex/core';
import { BackHandler } from 'react-native';
export default function useBackButton(
ref: React.RefObject<NavigationHelpers<ParamListBase>>
ref: React.RefObject<NavigationContainerRef>
) {
React.useEffect(() => {
const subscription = BackHandler.addEventListener(
'hardwareBackPress',
() => {
if (ref.current == null) {
const navigation = ref.current;
if (navigation == null) {
return false;
}
const navigation = ref.current;
if (navigation.canGoBack()) {
navigation.goBack();

View File

@@ -0,0 +1,82 @@
import * as React from 'react';
import { Linking } from 'react-native';
import {
getStateFromPath as getStateFromPathDefault,
NavigationContainerRef,
NavigationState,
PartialState,
} from '@navigation-ex/core';
type Options = {
/**
* The prefixes are stripped from the URL before parsing them.
* Usually they are the `scheme` + `host` (e.g. `myapp://chat?user=jane`)
*/
prefixes: string[];
/**
* Custom function to parse the URL object to a valid navigation state.
*/
getStateFromPath?: (
path: string
) => PartialState<NavigationState> | undefined;
};
export default function useLinking(
ref: React.RefObject<NavigationContainerRef>,
{ prefixes, getStateFromPath = getStateFromPathDefault }: Options
) {
// 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`
// Not re-creating `getInitialState` is important coz it makes it easier for the user to use in an effect
const prefixesRef = React.useRef(prefixes);
const getStateFromPathRef = React.useRef(getStateFromPath);
React.useEffect(() => {
prefixesRef.current = prefixes;
getStateFromPathRef.current = getStateFromPath;
}, [getStateFromPath, prefixes]);
const extractPathFromURL = React.useCallback((url: string) => {
for (const prefix of prefixesRef.current) {
if (url.startsWith(prefix)) {
return url.replace(prefix, '');
}
}
return undefined;
}, []);
const getInitialState = React.useCallback(async () => {
const url = await Linking.getInitialURL();
const path = url ? extractPathFromURL(url) : null;
if (path) {
return getStateFromPathRef.current(path);
} else {
return undefined;
}
}, [extractPathFromURL]);
React.useEffect(() => {
const listener = ({ url }: { url: string }) => {
const path = extractPathFromURL(url);
const navigation = ref.current;
if (navigation && path) {
const state = getStateFromPathRef.current(path);
if (state) {
navigation.resetRoot(state);
}
}
};
Linking.addEventListener('url', listener);
return () => Linking.removeEventListener('url', listener);
}, [extractPathFromURL, ref]);
return {
getInitialState,
};
}

View File

@@ -1,9 +0,0 @@
import * as React from 'react';
import { NavigationHelpers, ParamListBase } from '@navigation-ex/core';
import useBackButton from './useBackButton';
export default function useNativeIntegration(
ref: React.RefObject<NavigationHelpers<ParamListBase>>
) {
useBackButton(ref);
}

View File

@@ -85,7 +85,10 @@ export default function TabRouter({
return {
...route,
name,
key: route && route.key ? route.key : `${name}-${shortid()}`,
key:
route && route.name === name && route.key
? route.key
: `${name}-${shortid()}`,
params:
routeParamList[name] !== undefined
? {