Skip to content

Commit 2acf5c9

Browse files
authored
feat(react-navigation): add Detour linking adapter API and migrate examples to linking-based integration (#63)
* feat: integrate detour with react navigation linking * feat: document react navigation linking integration * feat: introduce useDetourReactNavigationLinking hook * feat: update metro.config.js for monorepo support * refactor: update react-navigation example with custom linking * refactor: rename AuthScreens to renderAuthScreens * feat: simplify auth-gated deep linking with React Navigation * docs: add dev-only warning explanation to README * docs: add note
1 parent 30666c7 commit 2acf5c9

22 files changed

Lines changed: 401 additions & 197 deletions

File tree

README.md

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,56 @@ export function RootNavigator() {
108108

109109
Learn more about usage from our [docs](https://docs.swmansion.com/detour/docs/SDK/sdk-usage)
110110

111+
### React Navigation linking integration
112+
113+
When integrating with React Navigation's custom linking API (`getInitialURL` + `subscribe`), use Detour as the URL source:
114+
115+
```ts
116+
import { DETOUR_LINKING_PREFIX, Detour } from "@swmansion/react-native-detour";
117+
118+
const linking = {
119+
prefixes: [DETOUR_LINKING_PREFIX],
120+
async getInitialURL() {
121+
return await Detour.getInitialURL();
122+
},
123+
subscribe(listener) {
124+
const subscription = Detour.addEventListener("url", ({ url }) => {
125+
listener(url);
126+
});
127+
128+
return () => subscription.remove();
129+
},
130+
};
131+
```
132+
133+
`DETOUR_LINKING_PREFIX` is an internal adapter prefix used for Detour-resolved routes.
134+
This API requires `DetourProvider` to be mounted above your `NavigationContainer`.
135+
136+
See React Navigation docs:
137+
https://reactnavigation.org/docs/deep-linking?config=static#integrating-with-other-tools
138+
139+
For auth-gated apps, let React Navigation hold the deep link until the right screen is reachable.
140+
Render screens conditionally on auth/onboarding state and opt in to React Navigation's pending-link
141+
behavior on the navigator:
142+
143+
```tsx
144+
<Stack.Navigator UNSTABLE_routeNamesChangeBehavior="lastUnhandled">
145+
{isSignedIn
146+
? isOnboardingCompleted
147+
? <>
148+
<Stack.Screen name="Tabs" component={TabNavigator} />
149+
<Stack.Screen name="Details" component={Details} />
150+
</>
151+
: <Stack.Screen name="Onboarding" component={Onboarding} />
152+
: <Stack.Screen name="SignIn" component={SignIn} />}
153+
</Stack.Navigator>
154+
```
155+
156+
A deep link that arrives while the user is signed-out is parsed, found unreachable (the target
157+
screen isn't currently rendered), and remembered. When the rendered screen set changes — after
158+
sign-in, then again after onboarding — React Navigation retries and lands the user on the target.
159+
See `examples/react-navigation-advanced` for a working setup.
160+
111161
### Controlling which links Detour processes
112162

113163
Use `linkProcessingMode` to control which link sources the SDK listens to:
@@ -138,10 +188,10 @@ All example apps with Detour SDK integrated live in `examples/`:
138188
| ------------------------------------ | -------------------------------------------------------------- |
139189
| `examples/expo-router` | Minimal Expo Router example (recommended starting point) |
140190
| `examples/expo-router-native-intent` | Expo Router with `+native-intent` handler |
141-
| `examples/expo-router-advanced` | Expo Router with auth flow and protected routes |
191+
| `examples/expo-router-advanced` | Expo Router with auth flow and custom native-intent |
142192
| `examples/expo-bare` | Expo without file-based routing (plain `index.js` entry point) |
143-
| `examples/react-navigation` | React Navigation example |
144-
| `examples/react-navigation-advanced` | React Navigation with auth flow |
193+
| `examples/react-navigation` | Minimal React Navigation example |
194+
| `examples/react-navigation-advanced` | React Navigation with auth + onboarding gated deep linking |
145195

146196
The monorepo uses **pnpm workspaces**. Start by installing all dependencies from the repo root:
147197

@@ -277,6 +327,25 @@ export type DetourLink = {
277327
} | null;
278328
```
279329

330+
### React Navigation adapter types
331+
332+
```js
333+
export const DETOUR_LINKING_PREFIX: string; // "detour://"
334+
335+
export type DetourUrlEvent = {
336+
url: string;
337+
};
338+
339+
export type DetourUrlSubscription = {
340+
remove: () => void;
341+
};
342+
```
343+
344+
```js
345+
Detour.getInitialURL(): Promise<string | undefined>
346+
Detour.addEventListener("url", (event: DetourUrlEvent) => void): DetourUrlSubscription
347+
```
348+
280349
---
281350

282351
## License
@@ -287,4 +356,4 @@ This library is licensed under [The MIT License](./LICENSE).
287356

288357
Since 2012, [Software Mansion](https://swmansion.com) is a software agency with experience in building web and mobile apps. We are Core React Native Contributors and experts in dealing with all kinds of React Native issues. We can help you build your next dream product – [Hire us](https://swmansion.com/contact/projects?utm_source=detour&utm_medium=readme).
289358

290-
[![swm](https://logo.swmansion.com/logo?color=white&variant=desktop&width=150&tag=react-native-executorch-github "Software Mansion")](https://swmansion.com)
359+
[![swm](https://logo.swmansion.com/logo?color=white&variant=desktop&width=150&tag=react-native-detour-github "Software Mansion")](https://swmansion.com)

examples/react-navigation-advanced/README.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,29 @@ This example demonstrates an auth-gated React Navigation app with Detour integra
66

77
- Auth flow with conditional screen rendering in a single stack (React Navigation standard pattern).
88
- Screens: `SignIn``Onboarding` (once per install) → `Tabs` (Home, Explore, Settings) + `Details`.
9-
- `useDetourGate` coordinates Detour link state with auth state — deferred links survive the full sign-in and onboarding flow.
10-
- Detour processes all link types (universal / app links, custom scheme, and deferred). Resolved links with pathname `/details` navigate to `Details`; anything else falls through to `NotFound`.
9+
- React Navigation linking uses the SDK adapter API as the URL source:
10+
- `Detour.getInitialURL()`
11+
- `Detour.addEventListener("url", ({ url }) => ...)`
12+
- The navigator opts into `UNSTABLE_routeNamesChangeBehavior="lastUnhandled"` so React Navigation remembers a deep link that hits a screen which is not currently rendered and replays it once that screen becomes part of the navigator.
13+
- Detour processes all link types (universal / app links, custom scheme, deferred) and the app maps routes via React Navigation linking config.
1114

1215
## Auth-gated deferred link behavior
1316

14-
- If a deferred link arrives and the user is not signed in, the splash hides and `SignIn` is shown. The link is preserved in Detour context.
15-
- After sign-in, `useDetourGate` re-fires. If onboarding has not been completed yet, `Onboarding` is shown first — the link is still kept alive.
16-
- After onboarding, `useDetourGate` re-fires again, clears the link, and navigates to the matched screen (`Details` or `NotFound`).
17+
- If a deferred link arrives and the user is not signed in, the splash hides and `SignIn` is shown. React Navigation parses the URL, finds `Details` is not currently rendered, and marks the action as the last unhandled one.
18+
- After sign-in, the rendered screen set changes. If onboarding has not been completed yet, `Onboarding` is shown — `Details` is still not rendered, so the pending link stays remembered.
19+
- After onboarding, `Details` becomes part of the rendered stack. React Navigation retries the unhandled action and navigates to `Details` (or falls through to `NotFound`).
20+
21+
> **Note:** `UNSTABLE_routeNamesChangeBehavior="lastUnhandled"` is not deep-link-specific. It also captures other unhandled navigation actions — for example a manual `navigation.navigate(...)` call or an `initialState` pointing at a screen that isn't currently rendered — and replays them once that screen becomes part of the navigator. See the React Navigation docs for the full behavior.
22+
23+
### Expected dev-only warning
24+
25+
When the link arrives while the target screen isn't rendered yet (e.g. on `SignIn`), React Navigation logs a development-only warning.
26+
27+
This is the dispatch attempt against the current (signed-out) navigator state. `UNSTABLE_routeNamesChangeBehavior="lastUnhandled"` then stashes the action and replays it once `Details` is part of the rendered stack. The message is stripped in production builds.
28+
29+
Reference docs:
30+
- https://reactnavigation.org/docs/deep-linking?config=static#integrating-with-other-tools
31+
- https://reactnavigation.org/docs/auth-flow (see `UNSTABLE_routeNamesChangeBehavior`)
1732

1833
## Test flow
1934

examples/react-navigation-advanced/app.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,18 @@
33
"name": "Detour React Navigation Advanced",
44
"slug": "detour-react-navigation-advanced",
55
"version": "1.0.0",
6-
"orientation": "portrait",
6+
"orientation": "default",
77
"icon": "./assets/detour-logo.png",
88
"newArchEnabled": true,
99
"scheme": "detour-react-navigation-advanced",
1010
"ios": {
11-
"bundleIdentifier": "swmansion.privatemind",
11+
"bundleIdentifier": "detourreactnative.reactnavigationadvanced",
1212
"supportsTablet": true,
1313
"icon": "./assets/detour-logo.png",
14-
"associatedDomains": ["applinks:privatemind.godetour.link"]
14+
"associatedDomains": ["applinks:<your-org>.godetour.link"]
1515
},
1616
"android": {
17-
"package": "swmansion.privatemind",
17+
"package": "detourreactnative.reactnavigationadvanced",
1818
"adaptiveIcon": {
1919
"foregroundImage": "./assets/detour-logo.png",
2020
"backgroundColor": "#0C1221"
@@ -27,8 +27,8 @@
2727
"data": [
2828
{
2929
"scheme": "https",
30-
"host": "privatemind.godetour.link",
31-
"pathPrefix": "/SneWQjYDGD"
30+
"host": "<your-org>.godetour.link",
31+
"pathPrefix": "/<your-app-hash>"
3232
}
3333
],
3434
"category": ["BROWSABLE", "DEFAULT"]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
11
const { getDefaultConfig } = require("@expo/metro-config");
2+
const path = require("path");
23

34
const config = getDefaultConfig(__dirname);
5+
const monorepoRoot = path.resolve(__dirname, "../..");
6+
const appNodeModules = path.resolve(__dirname, "node_modules");
7+
const rootNodeModules = path.resolve(monorepoRoot, "node_modules");
48

59
config.resolver.unstable_enablePackageExports = true;
610
config.resolver.unstable_conditionNames = ["react-native", "require", "default"];
11+
config.resolver.nodeModulesPaths = [appNodeModules, rootNodeModules];
12+
config.resolver.disableHierarchicalLookup = true;
13+
config.resolver.extraNodeModules = {
14+
...(config.resolver.extraNodeModules ?? {}),
15+
react: path.resolve(rootNodeModules, "react"),
16+
"react/jsx-runtime": path.resolve(rootNodeModules, "react/jsx-runtime.js"),
17+
"react/jsx-dev-runtime": path.resolve(rootNodeModules, "react/jsx-dev-runtime.js"),
18+
"react-native": path.resolve(rootNodeModules, "react-native"),
19+
};
720

821
module.exports = config;

examples/react-navigation-advanced/src/App.tsx

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
1-
import { useEffect, useState } from "react";
1+
import { useEffect, useMemo } from "react";
22

33
import { Text, View } from "react-native";
44

55
import * as SplashScreen from "expo-splash-screen";
66
import * as SystemUI from "expo-system-ui";
77

8-
import { NavigationContainer, useNavigationContainerRef } from "@react-navigation/native";
8+
import { type LinkingOptions, NavigationContainer } from "@react-navigation/native";
99

10-
import { type Config, DetourProvider } from "@swmansion/react-native-detour";
10+
import {
11+
type Config,
12+
DETOUR_LINKING_PREFIX,
13+
Detour,
14+
DetourProvider,
15+
} from "@swmansion/react-native-detour";
1116

1217
import { AuthProvider } from "./auth";
13-
import { Navigation, type RootStackParamList } from "./navigation";
18+
import { Navigation, type RootStackParamList, linkingConfig } from "./navigation";
1419
import { colors, styles } from "./styles";
15-
import { useDetourGate } from "./useDetourGate";
1620

1721
const hasCredentials =
1822
!!process.env.EXPO_PUBLIC_DETOUR_API_KEY && !!process.env.EXPO_PUBLIC_DETOUR_APP_ID;
@@ -48,25 +52,29 @@ export const detourConfig: Config = {
4852
SplashScreen.preventAutoHideAsync();
4953
SystemUI.setBackgroundColorAsync(colors.background);
5054

51-
const AppContent = ({
52-
navigationRef,
53-
isNavigationReady,
54-
}: {
55-
navigationRef: ReturnType<typeof useNavigationContainerRef<RootStackParamList>>;
56-
isNavigationReady: boolean;
57-
}) => {
58-
useDetourGate(navigationRef, isNavigationReady);
59-
return <Navigation />;
60-
};
61-
6255
const AppRoot = () => {
63-
const navigationRef = useNavigationContainerRef<RootStackParamList>();
64-
const [isNavigationReady, setNavigationReady] = useState(false);
56+
const linking = useMemo<LinkingOptions<RootStackParamList>>(
57+
() => ({
58+
prefixes: [DETOUR_LINKING_PREFIX],
59+
config: linkingConfig,
60+
async getInitialURL() {
61+
return await Detour.getInitialURL();
62+
},
63+
subscribe(listener) {
64+
const subscription = Detour.addEventListener("url", ({ url }) => {
65+
listener(url);
66+
});
67+
68+
return () => subscription.remove();
69+
},
70+
}),
71+
[],
72+
);
6573

6674
return (
6775
<NavigationContainer
68-
ref={navigationRef}
69-
onReady={() => setNavigationReady(true)}
76+
linking={linking}
77+
onReady={() => SplashScreen.hideAsync()}
7078
theme={{
7179
dark: true,
7280
colors: {
@@ -85,7 +93,7 @@ const AppRoot = () => {
8593
},
8694
}}
8795
>
88-
<AppContent navigationRef={navigationRef} isNavigationReady={isNavigationReady} />
96+
<Navigation />
8997
</NavigationContainer>
9098
);
9199
};

examples/react-navigation-advanced/src/navigation/index.tsx

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import type { LinkingOptions, NavigatorScreenParams } from "@react-navigation/native";
12
import { createNativeStackNavigator } from "@react-navigation/native-stack";
23

34
import { useAuth } from "../auth";
45
import { colors } from "../styles";
5-
import { TabNavigator } from "./TabNavigator";
6+
import { TabNavigator, type TabParamList } from "./TabNavigator";
67
import { Details } from "./screens/Details";
78
import { NotFound } from "./screens/NotFound";
89
import { Onboarding } from "./screens/Onboarding";
@@ -11,7 +12,7 @@ import { SignIn } from "./screens/SignIn";
1112
export type RootStackParamList = {
1213
SignIn: undefined;
1314
Onboarding: undefined;
14-
Tabs: undefined;
15+
Tabs: NavigatorScreenParams<TabParamList> | undefined;
1516
Details:
1617
| {
1718
fromDeepLink?: string;
@@ -22,14 +23,30 @@ export type RootStackParamList = {
2223
NotFound: { path?: string } | undefined;
2324
};
2425

26+
export const linkingConfig: NonNullable<LinkingOptions<RootStackParamList>["config"]> = {
27+
screens: {
28+
SignIn: "sign-in",
29+
Onboarding: "onboarding",
30+
Tabs: {
31+
screens: {
32+
Home: "",
33+
Explore: "explore",
34+
Settings: "settings",
35+
},
36+
},
37+
Details: "details",
38+
NotFound: "*",
39+
},
40+
};
41+
2542
const Stack = createNativeStackNavigator<RootStackParamList>();
2643

2744
const screenOptions = {
2845
headerShown: false,
2946
contentStyle: { backgroundColor: colors.background },
3047
};
3148

32-
function AuthScreens({
49+
function renderAuthScreens({
3350
isSignedIn,
3451
isOnboardingCompleted,
3552
}: {
@@ -51,16 +68,21 @@ function AuthScreens({
5168
}
5269

5370
// Auth flow using conditional screen rendering — equivalent of Stack.Protected in expo-router.
54-
// When isSignedIn or isOnboardingCompleted changes the navigator resets to the first valid screen.
71+
// `UNSTABLE_routeNamesChangeBehavior="lastUnhandled"` makes React Navigation remember a deep link
72+
// that hits a screen which isn't currently rendered (e.g. Details while signed-out) and replay it
73+
// once the navigator's screen set changes (after sign-in, then again after onboarding).
5574
// Returns null until auth is loaded from AsyncStorage so the splash covers the empty state.
5675
export function Navigation() {
5776
const { isLoaded, isSignedIn, isOnboardingCompleted } = useAuth();
5877

5978
if (!isLoaded) return null;
6079

6180
return (
62-
<Stack.Navigator screenOptions={screenOptions}>
63-
<AuthScreens isSignedIn={isSignedIn} isOnboardingCompleted={isOnboardingCompleted} />
81+
<Stack.Navigator
82+
screenOptions={screenOptions}
83+
UNSTABLE_routeNamesChangeBehavior="lastUnhandled"
84+
>
85+
{renderAuthScreens({ isSignedIn, isOnboardingCompleted })}
6486
<Stack.Screen name="NotFound" component={NotFound} />
6587
</Stack.Navigator>
6688
);

examples/react-navigation-advanced/src/navigation/screens/Explore.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ export function Explore() {
4343

4444
<Text style={styles.sectionHeader}>Deferred Link + Auth Gate</Text>
4545
<Text style={styles.bullet}>
46-
Copy a Detour link, sign out, then relaunch. The link survives sign-in {" "}
47-
<Text style={styles.accent}>useDetourGate</Text> picks it up once authenticated.
46+
Copy a Detour link, sign out, then relaunch. The link survives the sign-in flow — you'll
47+
land on the Details screen once you complete sign-in and onboarding.
4848
</Text>
4949
<Text style={styles.bullet}>
5050
Make sure <Text style={styles.accent}>Copy link feature enabled</Text> is turned on in

examples/react-navigation-advanced/src/navigation/screens/Home.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ export function Home() {
4343

4444
<Text style={styles.sectionHeader}>Deferred Link + Auth Gate</Text>
4545
<Text style={styles.bullet}>
46-
Copy a Detour link, sign out, then relaunch. The link survives sign-in {" "}
47-
<Text style={styles.accent}>useDetourGate</Text> picks it up once authenticated.
46+
Copy a Detour link, sign out, then relaunch. The link survives the sign-in flow — you'll
47+
land on the Details screen once you complete sign-in and onboarding.
4848
</Text>
4949
<Text style={styles.bullet}>
5050
Make sure <Text style={styles.accent}>Copy link feature enabled</Text> is turned on in

examples/react-navigation-advanced/src/navigation/screens/Onboarding.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,8 @@ export function Onboarding() {
4444
<Text style={styles.sectionHeader}>Deferred Link + Auth Gate</Text>
4545
<Text style={styles.bullet}>
4646
Copy a Detour link to your clipboard, sign out, then relaunch. The deferred link will
47-
survive the sign-in flow — once you authenticate,{" "}
48-
<Text style={styles.accent}>useDetourGate</Text> picks it back up and navigates
49-
automatically.
47+
survive the sign-in flow — once you finish sign-in and onboarding, React Navigation
48+
replays the link and lands you on Details automatically.
5049
</Text>
5150
<Text style={styles.bullet}>
5251
Make sure <Text style={styles.accent}>Copy link feature enabled</Text> is turned on in App

examples/react-navigation-advanced/src/navigation/screens/Settings.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ export function Settings() {
4343

4444
<Text style={styles.sectionHeader}>Deferred Link + Auth Gate</Text>
4545
<Text style={styles.bullet}>
46-
Copy a Detour link, sign out, then relaunch. The link survives sign-in {" "}
47-
<Text style={styles.accent}>useDetourGate</Text> picks it up once authenticated.
46+
Copy a Detour link, sign out, then relaunch. The link survives the sign-in flow — you'll
47+
land on the Details screen once you complete sign-in and onboarding.
4848
</Text>
4949
<Text style={styles.bullet}>
5050
Make sure <Text style={styles.accent}>Copy link feature enabled</Text> is turned on in

0 commit comments

Comments
 (0)