diff --git a/example/src/Screens/LinkComponent.tsx b/example/src/Screens/LinkComponent.tsx new file mode 100644 index 00000000..f6000a14 --- /dev/null +++ b/example/src/Screens/LinkComponent.tsx @@ -0,0 +1,140 @@ +import * as React from 'react'; +import { View, StyleSheet, ScrollView } from 'react-native'; +import { Button } from 'react-native-paper'; +import { + Link, + RouteProp, + ParamListBase, + useLinkTo, +} from '@react-navigation/native'; +import { + createStackNavigator, + StackNavigationProp, +} from '@react-navigation/stack'; +import Article from '../Shared/Article'; +import Albums from '../Shared/Albums'; + +type SimpleStackParams = { + Article: { author: string }; + Album: undefined; +}; + +type SimpleStackNavigation = StackNavigationProp; + +const LinkButton = ({ + to, + ...rest +}: React.ComponentProps & { to: string }) => { + const linkTo = useLinkTo(); + + return + +
+ + ); +}; + +const AlbumsScreen = ({ + navigation, +}: { + navigation: SimpleStackNavigation; +}) => { + return ( + + + + Go to /link-component/Article + + + Go to /link-component/Article + + + + + + ); +}; + +const SimpleStack = createStackNavigator(); + +type Props = Partial> & { + navigation: StackNavigationProp; +}; + +export default function SimpleStackScreen({ navigation, ...rest }: Props) { + navigation.setOptions({ + headerShown: false, + }); + + return ( + + ({ + title: `Article by ${route.params.author}`, + })} + initialParams={{ author: 'Gandalf' }} + /> + + + ); +} + +const styles = StyleSheet.create({ + buttons: { + padding: 8, + }, + button: { + margin: 8, + }, +}); diff --git a/example/src/index.tsx b/example/src/index.tsx index 66f7ee39..d847c5d8 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -22,10 +22,10 @@ import { Appbar, List, Divider, + Text, } from 'react-native-paper'; import { InitialState, - useLinking, NavigationContainerRef, NavigationContainer, DefaultTheme, @@ -55,6 +55,7 @@ import DynamicTabs from './Screens/DynamicTabs'; import AuthFlow from './Screens/AuthFlow'; import CompatAPI from './Screens/CompatAPI'; import MasterDetail from './Screens/MasterDetail'; +import LinkComponent from './Screens/LinkComponent'; YellowBox.ignoreWarnings(['Require cycle:', 'Warning: Async Storage']); @@ -113,6 +114,10 @@ const SCREENS = { title: 'Compat Layer', component: CompatAPI, }, + LinkComponent: { + title: '', + component: LinkComponent, + }, }; const Drawer = createDrawerNavigator(); @@ -126,34 +131,6 @@ Asset.loadAsync(StackAssets); export default function App() { const containerRef = React.useRef(null); - // 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 - // Android (bare): adb shell am start -a android.intent.action.VIEW -d "rne://127.0.0.1:19000/--/simple-stack" - // iOS (bare): xcrun simctl openurl booted rne://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: LinkingPrefixes, - config: { - Root: { - path: '', - initialRouteName: 'Home', - screens: Object.keys(SCREENS).reduce<{ [key: string]: string }>( - (acc, name) => { - // Convert screen names such as SimpleStack to kebab case (simple-stack) - acc[name] = name - .replace(/([A-Z]+)/g, '-$1') - .replace(/^-/, '') - .toLowerCase(); - - return acc; - }, - { Home: '' } - ), - }, - }, - }); - const [theme, setTheme] = React.useState(DefaultTheme); const [isReady, setIsReady] = React.useState(false); @@ -164,12 +141,13 @@ export default function App() { React.useEffect(() => { const restoreState = async () => { try { - let state = await getInitialState(); + let state; if (Platform.OS !== 'web' && state === undefined) { const savedState = await AsyncStorage.getItem( NAVIGATION_PERSISTENCE_KEY ); + state = savedState ? JSON.parse(savedState) : undefined; } @@ -190,7 +168,7 @@ export default function App() { }; restoreState(); - }, [getInitialState]); + }, []); const paperTheme = React.useMemo(() => { const t = theme.dark ? PaperDarkTheme : PaperLightTheme; @@ -239,6 +217,34 @@ export default function App() { ) } theme={theme} + linking={{ + // 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 + // Android (bare): adb shell am start -a android.intent.action.VIEW -d "rne://127.0.0.1:19000/--/simple-stack" + // iOS (bare): xcrun simctl openurl booted rne://127.0.0.1:19000/--/simple-stack + // The first segment of the link is the the scheme + host (returned by `Linking.makeUrl`) + prefixes: LinkingPrefixes, + config: { + Root: { + path: '', + initialRouteName: 'Home', + screens: Object.keys(SCREENS).reduce<{ [key: string]: string }>( + (acc, name) => { + // Convert screen names such as SimpleStack to kebab case (simple-stack) + acc[name] = name + .replace(/([A-Z]+)/g, '-$1') + .replace(/^-/, '') + .toLowerCase(); + + return acc; + }, + { Home: '' } + ), + }, + }, + }} + fallback={Loading…} > { + if ('onPress' in rest) { + rest.onPress?.(e as GestureResponderEvent); + } + + const event = (e?.nativeEvent as any) as + | React.MouseEvent + | undefined; + + if (Platform.OS !== 'web' || !event) { + linkTo(to); + return; + } + + event.preventDefault(); + + if ( + !event.defaultPrevented && // onPress prevented default + !(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) && // ignore clicks with modifier keys + (event.button == null || event.button === 0) && // ignore everything but left clicks + (rest.target == null || rest.target === '_self') // let browser handle "target=_blank" etc. + ) { + event.preventDefault(); + linkTo(to); + } + }; + + const props = { + href: to, + onPress, + accessibilityRole: 'link' as const, + ...rest, + }; + + return {children}; +} diff --git a/packages/native/src/LinkingContext.tsx b/packages/native/src/LinkingContext.tsx new file mode 100644 index 00000000..7bdffef7 --- /dev/null +++ b/packages/native/src/LinkingContext.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { LinkingOptions } from './types'; + +const LinkingContext = React.createContext<() => LinkingOptions | undefined>( + () => { + throw new Error( + "Couldn't find a linking context. Have you wrapped your app with 'NavigationContainer'?" + ); + } +); + +export default LinkingContext; diff --git a/packages/native/src/NavigationContainer.tsx b/packages/native/src/NavigationContainer.tsx index 9c5c45fe..215298f7 100644 --- a/packages/native/src/NavigationContainer.tsx +++ b/packages/native/src/NavigationContainer.tsx @@ -6,38 +6,76 @@ import { } from '@react-navigation/core'; import ThemeProvider from './theming/ThemeProvider'; import DefaultTheme from './theming/DefaultTheme'; +import LinkingContext from './LinkingContext'; +import useThenable from './useThenable'; +import useLinking from './useLinking'; import useBackButton from './useBackButton'; -import { Theme } from './types'; +import { Theme, LinkingOptions } from './types'; type Props = NavigationContainerProps & { theme?: Theme; + linking?: LinkingOptions; + fallback?: React.ReactNode; }; /** - * Container component which holds the navigation state - * designed for mobile apps. + * Container component which holds the navigation state designed for React Native apps. * This should be rendered at the root wrapping the whole app. * - * @param props.initialState Initial state object for the navigation tree. + * @param props.initialState Initial state object for the navigation tree. When deep link handling is enabled, this will be ignored if there's an incoming link. * @param props.onStateChange Callback which is called with the latest navigation state when it changes. * @param props.theme Theme object for the navigators. + * @param props.linking Options for deep linking. Deep link handling is enabled when this prop is provided, unless `linking.enabled` is `false`. + * @param props.fallback Fallback component to render until we have finished getting initial state when linking is enabled. Defaults to `null`. * @param props.children Child elements to render the content. * @param props.ref Ref object which refers to the navigation object containing helper methods. */ const NavigationContainer = React.forwardRef(function NavigationContainer( - { theme = DefaultTheme, ...rest }: Props, + { theme = DefaultTheme, linking, fallback = null, ...rest }: Props, ref?: React.Ref ) { + const isLinkingEnabled = linking ? linking.enabled !== false : false; + const refContainer = React.useRef(null); useBackButton(refContainer); + const { getInitialState } = useLinking(refContainer, { + enabled: isLinkingEnabled, + prefixes: [], + ...linking, + }); + + const [isReady, initialState = rest.initialState] = useThenable( + getInitialState + ); + React.useImperativeHandle(ref, () => refContainer.current); + const linkingOptionsRef = React.useRef(linking); + + React.useEffect(() => { + linkingOptionsRef.current = linking; + }); + + const linkingContext = React.useCallback(() => linkingOptionsRef.current, []); + + if (!isReady) { + // This is temporary until we have Suspense for data-fetching + // Then the fallback will be handled by a parent `Suspense` component + return fallback as React.ReactElement; + } + return ( - - - + + + + + ); }); diff --git a/packages/native/src/NavigationNativeContainer.tsx b/packages/native/src/NavigationNativeContainer.tsx deleted file mode 100644 index 51052841..00000000 --- a/packages/native/src/NavigationNativeContainer.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export default function () { - throw new Error( - "'NavigationNativeContainer' has been renamed to 'NavigationContainer" - ); -} diff --git a/packages/native/src/index.tsx b/packages/native/src/index.tsx index 852b5485..a95cf5eb 100644 --- a/packages/native/src/index.tsx +++ b/packages/native/src/index.tsx @@ -1,13 +1,15 @@ export * from '@react-navigation/core'; export { default as NavigationContainer } from './NavigationContainer'; -export { default as NavigationNativeContainer } from './NavigationNativeContainer'; export { default as useBackButton } from './useBackButton'; -export { default as useLinking } from './useLinking'; export { default as useScrollToTop } from './useScrollToTop'; export { default as DefaultTheme } from './theming/DefaultTheme'; export { default as DarkTheme } from './theming/DarkTheme'; export { default as ThemeProvider } from './theming/ThemeProvider'; export { default as useTheme } from './theming/useTheme'; + +export { default as Link } from './Link'; +export { default as useLinking } from './useLinking'; +export { default as useLinkTo } from './useLinkTo'; diff --git a/packages/native/src/types.tsx b/packages/native/src/types.tsx index 388454db..a22c2c36 100644 --- a/packages/native/src/types.tsx +++ b/packages/native/src/types.tsx @@ -15,6 +15,11 @@ export type Theme = { }; export type LinkingOptions = { + /** + * Whether deep link handling should be enabled. + * Defaults to true. + */ + enabled?: boolean; /** * The prefixes are stripped from the URL before parsing them. * Usually they are the `scheme` + `host` (e.g. `myapp://chat?user=jane`) diff --git a/packages/native/src/useLinkTo.tsx b/packages/native/src/useLinkTo.tsx new file mode 100644 index 00000000..93bc6f20 --- /dev/null +++ b/packages/native/src/useLinkTo.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { + useNavigation, + getStateFromPath, + getActionFromState, +} from '@react-navigation/core'; +import LinkingContext from './LinkingContext'; + +export default function useLinkTo() { + const navigation = useNavigation(); + const getOptions = React.useContext(LinkingContext); + + const linkTo = React.useCallback( + (path: string) => { + if (!path.startsWith('/')) { + throw new Error(`The path must start with '/' (${path}).`); + } + + const options = getOptions(); + + const state = options?.getStateFromPath + ? options.getStateFromPath(path, options.config) + : getStateFromPath(path, options?.config); + + if (state) { + let root = navigation; + let current; + + // Traverse up to get the root navigation + while ((current = root.dangerouslyGetParent())) { + root = current; + } + + const action = getActionFromState(state); + + if (action !== undefined) { + root.dispatch(action); + } else { + root.reset(state); + } + } else { + throw new Error('Failed to parse the path to a navigation state.'); + } + }, + [getOptions, navigation] + ); + + return linkTo; +} diff --git a/packages/native/src/useLinking.native.tsx b/packages/native/src/useLinking.native.tsx index 54b184f0..d14cc86c 100644 --- a/packages/native/src/useLinking.native.tsx +++ b/packages/native/src/useLinking.native.tsx @@ -12,6 +12,7 @@ let isUsingLinking = false; export default function useLinking( ref: React.RefObject, { + enabled, prefixes, config, getStateFromPath = getStateFromPathDefault, @@ -37,15 +38,17 @@ export default function useLinking( // 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 enabledRef = React.useRef(enabled); const prefixesRef = React.useRef(prefixes); const configRef = React.useRef(config); const getStateFromPathRef = React.useRef(getStateFromPath); React.useEffect(() => { + enabledRef.current = enabled; prefixesRef.current = prefixes; configRef.current = config; getStateFromPathRef.current = getStateFromPath; - }, [config, getStateFromPath, prefixes]); + }, [config, enabled, getStateFromPath, prefixes]); const extractPathFromURL = React.useCallback((url: string) => { for (const prefix of prefixesRef.current) { @@ -58,7 +61,19 @@ export default function useLinking( }, []); const getInitialState = React.useCallback(async () => { - const url = await Linking.getInitialURL(); + if (!enabledRef.current) { + return undefined; + } + + const url = await (Promise.race([ + Linking.getInitialURL(), + new Promise((resolve) => + // Timeout in 150ms if `getInitialState` doesn't resolve + // Workaround for https://github.com/facebook/react-native/issues/25675 + setTimeout(resolve, 150) + ), + ]) as Promise); + const path = url ? extractPathFromURL(url) : null; if (path) { @@ -70,6 +85,10 @@ export default function useLinking( React.useEffect(() => { const listener = ({ url }: { url: string }) => { + if (!enabled) { + return; + } + const path = extractPathFromURL(url); const navigation = ref.current; @@ -91,7 +110,7 @@ export default function useLinking( Linking.addEventListener('url', listener); return () => Linking.removeEventListener('url', listener); - }, [extractPathFromURL, ref]); + }, [enabled, extractPathFromURL, ref]); return { getInitialState, diff --git a/packages/native/src/useLinking.tsx b/packages/native/src/useLinking.tsx index ef09bf90..036f57eb 100644 --- a/packages/native/src/useLinking.tsx +++ b/packages/native/src/useLinking.tsx @@ -8,6 +8,8 @@ import { } from '@react-navigation/core'; import { LinkingOptions } from './types'; +type ResultState = ReturnType; + const getStateLength = (state: NavigationState) => { let length = 0; @@ -32,6 +34,7 @@ let isUsingLinking = false; export default function useLinking( ref: React.RefObject, { + enabled, config, getStateFromPath = getStateFromPathDefault, getPathFromState = getPathFromStateDefault, @@ -54,25 +57,34 @@ export default function useLinking( // 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 enabledRef = React.useRef(enabled); const configRef = React.useRef(config); const getStateFromPathRef = React.useRef(getStateFromPath); const getPathFromStateRef = React.useRef(getPathFromState); React.useEffect(() => { + enabledRef.current = enabled; configRef.current = config; getStateFromPathRef.current = getStateFromPath; getPathFromStateRef.current = getPathFromState; - }, [config, getPathFromState, getStateFromPath]); + }, [config, enabled, getPathFromState, getStateFromPath]); - // Make it an async function to keep consistent with the native impl - const getInitialState = React.useCallback(async () => { - const path = location.pathname + location.search; + const getInitialState = React.useCallback(() => { + let value: ResultState | undefined; - if (path) { - return getStateFromPathRef.current(path, configRef.current); - } else { - return undefined; + if (enabledRef.current) { + const path = location.pathname + location.search; + + if (path) { + value = getStateFromPathRef.current(path, configRef.current); + } } + + // Make it a thenable to keep consistent with the native impl + return { + then: (callback: (state: ResultState | undefined) => void) => + callback(value), + }; }, []); const previousStateLengthRef = React.useRef(undefined); @@ -92,10 +104,10 @@ export default function useLinking( const numberOfIndicesAhead = React.useRef(0); React.useEffect(() => { - window.addEventListener('popstate', () => { + const onPopState = () => { const navigation = ref.current; - if (!navigation) { + if (!navigation || !enabled) { return; } @@ -169,10 +181,18 @@ export default function useLinking( } } } - }); - }, [ref]); + }; + + window.addEventListener('popstate', onPopState); + + return () => window.removeEventListener('popstate', onPopState); + }, [enabled, ref]); React.useEffect(() => { + if (!enabled) { + return; + } + if (ref.current && previousStateLengthRef.current === undefined) { previousStateLengthRef.current = getStateLength( ref.current.getRootState() diff --git a/packages/native/src/useThenable.tsx b/packages/native/src/useThenable.tsx new file mode 100644 index 00000000..292f6930 --- /dev/null +++ b/packages/native/src/useThenable.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; + +export default function useThenable( + create: () => { + then(success: (result: T) => void, error?: (error: any) => void): void; + } +) { + const [promise] = React.useState(create); + + // Check if our thenable is synchronous + let resolved = false; + let value: T | undefined; + + promise.then((result) => { + resolved = true; + value = result; + }); + + const [state, setState] = React.useState<[boolean, T | undefined]>([ + resolved, + value, + ]); + + React.useEffect(() => { + let cancelled = false; + + if (!resolved) { + promise.then( + (result) => { + if (!cancelled) { + setState([true, result]); + } + }, + (error) => { + if (!cancelled) { + console.error(error); + setState([true, undefined]); + } + } + ); + } + + return () => { + cancelled = true; + }; + }, [promise, resolved]); + + return state; +}