From 35987ae3699b44cb47b39ea0c050b4074e77aad6 Mon Sep 17 00:00:00 2001 From: "satyajit.happy" Date: Sun, 18 Aug 2019 16:14:45 +0530 Subject: [PATCH] feat: add hook for deep link support --- packages/core/src/BaseActions.tsx | 6 +- .../core/src/NavigationBuilderContext.tsx | 2 +- packages/core/src/NavigationContainer.tsx | 17 ++-- .../core/src/__tests__/BaseRouter.test.tsx | 1 + .../__tests__/NavigationContainer.test.tsx | 77 ++++++++++++++++- packages/core/src/getStateFromPath.tsx | 10 +-- packages/core/src/types.tsx | 16 +++- packages/core/src/useDevTools.tsx | 8 +- packages/core/src/useNavigationBuilder.tsx | 42 ++++++---- packages/core/src/useOnAction.tsx | 2 +- packages/example/src/index.tsx | 66 +++++++++++---- packages/native/src/index.tsx | 2 +- packages/native/src/useBackButton.tsx | 10 +-- packages/native/src/useLinking.tsx | 82 +++++++++++++++++++ packages/native/src/useNativeIntegration.tsx | 9 -- packages/routers/src/TabRouter.tsx | 5 +- 16 files changed, 278 insertions(+), 77 deletions(-) create mode 100644 packages/native/src/useLinking.tsx delete mode 100644 packages/native/src/useNativeIntegration.tsx diff --git a/packages/core/src/BaseActions.tsx b/packages/core/src/BaseActions.tsx index da74bf95..d0cae055 100644 --- a/packages/core/src/BaseActions.tsx +++ b/packages/core/src/BaseActions.tsx @@ -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; + payload: PartialState; 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): Action { +export function reset(state: PartialState): Action { return { type: 'RESET', payload: state }; } diff --git a/packages/core/src/NavigationBuilderContext.tsx b/packages/core/src/NavigationBuilderContext.tsx index 056bd9df..aaa09871 100644 --- a/packages/core/src/NavigationBuilderContext.tsx +++ b/packages/core/src/NavigationBuilderContext.tsx @@ -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, }); diff --git a/packages/core/src/NavigationContainer.tsx b/packages/core/src/NavigationContainer.tsx index 3485afee..7ff11f74 100644 --- a/packages/core/src/NavigationContainer.tsx +++ b/packages/core/src/NavigationContainer.tsx @@ -10,21 +10,18 @@ import { NavigationState, InitialState, PartialState, - ParamListBase, - NavigationHelpers, NavigationAction, + NavigationContainerRef, } from './types'; +type State = NavigationState | PartialState | undefined; + type Props = { initialState?: InitialState; - onStateChange?: ( - state: NavigationState | PartialState | undefined - ) => void; + onStateChange?: (state: State) => void; children: React.ReactNode; }; -type State = NavigationState | PartialState | 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> + ref: React.Ref ) { const [state, setNavigationState] = React.useState(() => getPartialState(initialState) @@ -126,6 +123,10 @@ const Container = React.forwardRef(function NavigationContainer( ); return acc; }, {}), + resetRoot: (state: PartialState | NavigationState) => { + trackAction('@@RESET_ROOT'); + setNavigationState(state); + }, dispatch, canGoBack, })); diff --git a/packages/core/src/__tests__/BaseRouter.test.tsx b/packages/core/src/__tests__/BaseRouter.test.tsx index 14386c37..f90dffe7 100644 --- a/packages/core/src/__tests__/BaseRouter.test.tsx +++ b/packages/core/src/__tests__/BaseRouter.test.tsx @@ -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'] }) ); diff --git a/packages/core/src/__tests__/NavigationContainer.test.tsx b/packages/core/src/__tests__/NavigationContainer.test.tsx index f7fda34a..49e10fe2 100644 --- a/packages/core/src/__tests__/NavigationContainer.test.tsx +++ b/packages/core/src/__tests__/NavigationContainer.test.tsx @@ -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>(); + const ref = React.createRef(); 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 ( + + {state.routes.map(route => descriptors[route.key].render())} + + ); + }; + + const ref = React.createRef(); + + const onStateChange = jest.fn(); + + const element = ( + + + {() => null} + + {() => ( + + null} /> + null} /> + + )} + + {() => null} + + {() => ( + + null} /> + null} /> + + )} + + + + ); + + 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); +}); diff --git a/packages/core/src/getStateFromPath.tsx b/packages/core/src/getStateFromPath.tsx index bc2fc599..da482249 100644 --- a/packages/core/src/getStateFromPath.tsx +++ b/packages/core/src/getStateFromPath.tsx @@ -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 | 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 | undefined; + let current: PartialState | undefined; while (segments.length) { const state = { - stale: true as true, + stale: true, routes: [{ name: decodeURIComponent(segments[0]) }], }; diff --git a/packages/core/src/types.tsx b/packages/core/src/types.tsx index 9402236a..3a0fa020 100644 --- a/packages/core/src/types.tsx +++ b/packages/core/src/types.tsx @@ -37,7 +37,7 @@ export type InitialState = Partial< export type PartialState = Partial< Omit > & { - stale: boolean; + stale?: boolean; routes: Array< Omit, 'key'> & { key?: string; state?: InitialState } >; @@ -304,7 +304,7 @@ type NavigationHelpersCommon< * * @param state Navigation state object. */ - reset(state: Partial): void; + reset(state: PartialState | 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 & { + /** + * Reset the navigation state of the root navigator to the provided state. + * + * @param state Navigation state object. + */ + resetRoot(state: PartialState | NavigationState): void; + } + | undefined + | null; + export type TypedNavigator< ParamList extends ParamListBase, ScreenOptions extends object, diff --git a/packages/core/src/useDevTools.tsx b/packages/core/src/useDevTools.tsx index e0de4b41..8e439347 100644 --- a/packages/core/src/useDevTools.tsx +++ b/packages/core/src/useDevTools.tsx @@ -43,9 +43,7 @@ export default function useDevTools({ name, reset, state }: Options) { const devTools = devToolsRef.current; const lastStateRef = React.useRef(state); - const actions = React.useRef< - Array<{ key: string; action: NavigationAction }> - >([]); + const actions = React.useRef>([]); 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] ); diff --git a/packages/core/src/useNavigationBuilder.tsx b/packages/core/src/useNavigationBuilder.tsx index 7ae0d181..c1a37ed9 100644 --- a/packages/core/src/useNavigationBuilder.tsx +++ b/packages/core/src/useNavigationBuilder.tsx @@ -129,27 +129,41 @@ export default function useNavigationBuilder< performTransaction, } = React.useContext(NavigationStateContext); - const [initialState] = React.useState(() => + const previousStateRef = React.useRef< + NavigationState | PartialState | undefined + >(); + const initializedStateRef = React.useRef(); + + 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, { - routeNames, - routeParamList, - }) - ); + initializedStateRef.current = + currentState === undefined + ? router.getInitialState({ + routeNames, + routeParamList, + }) + : router.getRehydratedState(currentState as PartialState, { + 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(); diff --git a/packages/core/src/useOnAction.tsx b/packages/core/src/useOnAction.tsx index 1326e463..4f6b4fbb 100644 --- a/packages/core/src/useOnAction.tsx +++ b/packages/core/src/useOnAction.tsx @@ -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); diff --git a/packages/example/src/index.tsx b/packages/example/src/index.tsx index fc42cafa..3dc56448 100644 --- a/packages/example/src/index.tsx +++ b/packages/example/src/index.tsx @@ -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>(null); + const containerRef = React.useRef(); - 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; diff --git a/packages/native/src/index.tsx b/packages/native/src/index.tsx index 77fb4f0d..a1ef1fcc 100644 --- a/packages/native/src/index.tsx +++ b/packages/native/src/index.tsx @@ -1,2 +1,2 @@ export { default as useBackButton } from './useBackButton'; -export { default as useNativeIntegration } from './useNativeIntegration'; +export { default as useLinking } from './useLinking'; diff --git a/packages/native/src/useBackButton.tsx b/packages/native/src/useBackButton.tsx index d3b66ced..41b23e98 100644 --- a/packages/native/src/useBackButton.tsx +++ b/packages/native/src/useBackButton.tsx @@ -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> + ref: React.RefObject ) { 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(); diff --git a/packages/native/src/useLinking.tsx b/packages/native/src/useLinking.tsx new file mode 100644 index 00000000..a005bb68 --- /dev/null +++ b/packages/native/src/useLinking.tsx @@ -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 | undefined; +}; + +export default function useLinking( + ref: React.RefObject, + { 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, + }; +} diff --git a/packages/native/src/useNativeIntegration.tsx b/packages/native/src/useNativeIntegration.tsx deleted file mode 100644 index 4ec31062..00000000 --- a/packages/native/src/useNativeIntegration.tsx +++ /dev/null @@ -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> -) { - useBackButton(ref); -} diff --git a/packages/routers/src/TabRouter.tsx b/packages/routers/src/TabRouter.tsx index 624f9d91..cb5f800d 100644 --- a/packages/routers/src/TabRouter.tsx +++ b/packages/routers/src/TabRouter.tsx @@ -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 ? {