feat: add Link component as useLinkTo hook for navigating to links

The `Link` component can be used to navigate to URLs. On web, it'll use an `a` tag for proper accessibility. On React Native, it'll use a `Text`.

Example:

```js
<Link to="/feed/hot">Go to 🔥</Link>
```

Sometimes we might want more complex styling and more control over the behaviour, or navigate to a URL programmatically. The `useLinkTo` hook can be used for that.

Example:

```js
function LinkButton({ to, ...rest }) {
  const linkTo = useLinkTo();

  return (
    <Button
      {...rest}
      href={to}
      onPress={(e) => {
        e.preventDefault();
        linkTo(to);
      }}
    />
  );
}
```
This commit is contained in:
satyajit.happy
2019-10-02 22:26:19 +02:00
committed by Satyajit Sahoo
parent 2697355ab2
commit 2573b5beaa
12 changed files with 444 additions and 61 deletions

View File

@@ -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<SimpleStackParams>;
const LinkButton = ({
to,
...rest
}: React.ComponentProps<typeof Button> & { to: string }) => {
const linkTo = useLinkTo();
return <Button onPress={() => linkTo(to)} {...rest} />;
};
const ArticleScreen = ({
navigation,
route,
}: {
navigation: SimpleStackNavigation;
route: RouteProp<SimpleStackParams, 'Article'>;
}) => {
return (
<ScrollView>
<View style={styles.buttons}>
<Link
to="/link-component/Album"
style={[styles.button, { padding: 8 }]}
>
Go to /link-component/Album
</Link>
<LinkButton
to="/link-component/Album"
mode="contained"
style={styles.button}
>
Go to /link-component/Album
</LinkButton>
<Button
mode="outlined"
onPress={() => navigation.goBack()}
style={styles.button}
>
Go back
</Button>
</View>
<Article author={{ name: route.params.author }} scrollEnabled={false} />
</ScrollView>
);
};
const AlbumsScreen = ({
navigation,
}: {
navigation: SimpleStackNavigation;
}) => {
return (
<ScrollView>
<View style={styles.buttons}>
<Link
to="/link-component/Article?author=Babel"
style={[styles.button, { padding: 8 }]}
>
Go to /link-component/Article
</Link>
<LinkButton
to="/link-component/Article?author=Babel"
mode="contained"
style={styles.button}
>
Go to /link-component/Article
</LinkButton>
<Button
mode="outlined"
onPress={() => navigation.goBack()}
style={styles.button}
>
Go back
</Button>
</View>
<Albums scrollEnabled={false} />
</ScrollView>
);
};
const SimpleStack = createStackNavigator<SimpleStackParams>();
type Props = Partial<React.ComponentProps<typeof SimpleStack.Navigator>> & {
navigation: StackNavigationProp<ParamListBase>;
};
export default function SimpleStackScreen({ navigation, ...rest }: Props) {
navigation.setOptions({
headerShown: false,
});
return (
<SimpleStack.Navigator {...rest}>
<SimpleStack.Screen
name="Article"
component={ArticleScreen}
options={({ route }) => ({
title: `Article by ${route.params.author}`,
})}
initialParams={{ author: 'Gandalf' }}
/>
<SimpleStack.Screen
name="Album"
component={AlbumsScreen}
options={{ title: 'Album' }}
/>
</SimpleStack.Navigator>
);
}
const styles = StyleSheet.create({
buttons: {
padding: 8,
},
button: {
margin: 8,
},
});

View File

@@ -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: '<Link />',
component: LinkComponent,
},
};
const Drawer = createDrawerNavigator<RootDrawerParamList>();
@@ -126,34 +131,6 @@ Asset.loadAsync(StackAssets);
export default function App() {
const containerRef = React.useRef<NavigationContainerRef>(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={<Text>Loading</Text>}
>
<Drawer.Navigator drawerType={isLargeScreen ? 'permanent' : undefined}>
<Drawer.Screen

View File

@@ -0,0 +1,48 @@
import * as React from 'react';
import { Text, TextProps, GestureResponderEvent, Platform } from 'react-native';
import useLinkTo from './useLinkTo';
type Props = {
to: string;
target?: string;
} & (TextProps & { children: React.ReactNode });
export default function Link({ to, children, ...rest }: Props) {
const linkTo = useLinkTo();
const onPress = (e: GestureResponderEvent | undefined) => {
if ('onPress' in rest) {
rest.onPress?.(e as GestureResponderEvent);
}
const event = (e?.nativeEvent as any) as
| React.MouseEvent<HTMLAnchorElement, 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 <Text {...props}>{children}</Text>;
}

View File

@@ -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;

View File

@@ -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<NavigationContainerRef | null>
) {
const isLinkingEnabled = linking ? linking.enabled !== false : false;
const refContainer = React.useRef<NavigationContainerRef>(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 (
<ThemeProvider value={theme}>
<BaseNavigationContainer {...rest} ref={refContainer} />
</ThemeProvider>
<LinkingContext.Provider value={linkingContext}>
<ThemeProvider value={theme}>
<BaseNavigationContainer
{...rest}
initialState={initialState}
ref={refContainer}
/>
</ThemeProvider>
</LinkingContext.Provider>
);
});

View File

@@ -1,5 +0,0 @@
export default function () {
throw new Error(
"'NavigationNativeContainer' has been renamed to 'NavigationContainer"
);
}

View File

@@ -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';

View File

@@ -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`)

View File

@@ -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;
}

View File

@@ -12,6 +12,7 @@ let isUsingLinking = false;
export default function useLinking(
ref: React.RefObject<NavigationContainerRef>,
{
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<string | null | undefined>);
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,

View File

@@ -8,6 +8,8 @@ import {
} from '@react-navigation/core';
import { LinkingOptions } from './types';
type ResultState = ReturnType<typeof getStateFromPathDefault>;
const getStateLength = (state: NavigationState) => {
let length = 0;
@@ -32,6 +34,7 @@ let isUsingLinking = false;
export default function useLinking(
ref: React.RefObject<NavigationContainerRef>,
{
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<number | undefined>(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()

View File

@@ -0,0 +1,49 @@
import * as React from 'react';
export default function useThenable<T>(
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;
}