Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 73 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,56 @@ export function RootNavigator() {

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

### React Navigation linking integration

When integrating with React Navigation's custom linking API (`getInitialURL` + `subscribe`), use Detour as the URL source:

```ts
import { DETOUR_LINKING_PREFIX, Detour } from "@swmansion/react-native-detour";

const linking = {
prefixes: [DETOUR_LINKING_PREFIX],
async getInitialURL() {
return await Detour.getInitialURL();
},
subscribe(listener) {
const subscription = Detour.addEventListener("url", ({ url }) => {
listener(url);
});

return () => subscription.remove();
},
};
```

`DETOUR_LINKING_PREFIX` is an internal adapter prefix used for Detour-resolved routes.
This API requires `DetourProvider` to be mounted above your `NavigationContainer`.

See React Navigation docs:
https://reactnavigation.org/docs/deep-linking?config=static#integrating-with-other-tools

For auth-gated apps, let React Navigation hold the deep link until the right screen is reachable.
Render screens conditionally on auth/onboarding state and opt in to React Navigation's pending-link
behavior on the navigator:

```tsx
<Stack.Navigator UNSTABLE_routeNamesChangeBehavior="lastUnhandled">
{isSignedIn
? isOnboardingCompleted
? <>
<Stack.Screen name="Tabs" component={TabNavigator} />
<Stack.Screen name="Details" component={Details} />
</>
: <Stack.Screen name="Onboarding" component={Onboarding} />
: <Stack.Screen name="SignIn" component={SignIn} />}
</Stack.Navigator>
```

A deep link that arrives while the user is signed-out is parsed, found unreachable (the target
screen isn't currently rendered), and remembered. When the rendered screen set changes — after
sign-in, then again after onboarding — React Navigation retries and lands the user on the target.
See `examples/react-navigation-advanced` for a working setup.

### Controlling which links Detour processes

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

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

Expand Down Expand Up @@ -277,6 +327,25 @@ export type DetourLink = {
} | null;
```

### React Navigation adapter types

```js
export const DETOUR_LINKING_PREFIX: string; // "detour://"

export type DetourUrlEvent = {
url: string;
};

export type DetourUrlSubscription = {
remove: () => void;
};
```

```js
Detour.getInitialURL(): Promise<string | undefined>
Detour.addEventListener("url", (event: DetourUrlEvent) => void): DetourUrlSubscription
```

---

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

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).

[![swm](https://logo.swmansion.com/logo?color=white&variant=desktop&width=150&tag=react-native-executorch-github "Software Mansion")](https://swmansion.com)
[![swm](https://logo.swmansion.com/logo?color=white&variant=desktop&width=150&tag=react-native-detour-github "Software Mansion")](https://swmansion.com)
25 changes: 20 additions & 5 deletions examples/react-navigation-advanced/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,29 @@ This example demonstrates an auth-gated React Navigation app with Detour integra

- Auth flow with conditional screen rendering in a single stack (React Navigation standard pattern).
- Screens: `SignIn` → `Onboarding` (once per install) → `Tabs` (Home, Explore, Settings) + `Details`.
- `useDetourGate` coordinates Detour link state with auth state — deferred links survive the full sign-in and onboarding flow.
- 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`.
- React Navigation linking uses the SDK adapter API as the URL source:
- `Detour.getInitialURL()`
- `Detour.addEventListener("url", ({ url }) => ...)`
- 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.
- Detour processes all link types (universal / app links, custom scheme, deferred) and the app maps routes via React Navigation linking config.

## Auth-gated deferred link behavior

- 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.
- After sign-in, `useDetourGate` re-fires. If onboarding has not been completed yet, `Onboarding` is shown first — the link is still kept alive.
- After onboarding, `useDetourGate` re-fires again, clears the link, and navigates to the matched screen (`Details` or `NotFound`).
- 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.
- 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.
- After onboarding, `Details` becomes part of the rendered stack. React Navigation retries the unhandled action and navigates to `Details` (or falls through to `NotFound`).

> **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.

### Expected dev-only warning

When the link arrives while the target screen isn't rendered yet (e.g. on `SignIn`), React Navigation logs a development-only warning.

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.

Reference docs:
- https://reactnavigation.org/docs/deep-linking?config=static#integrating-with-other-tools
- https://reactnavigation.org/docs/auth-flow (see `UNSTABLE_routeNamesChangeBehavior`)

## Test flow

Expand Down
12 changes: 6 additions & 6 deletions examples/react-navigation-advanced/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@
"name": "Detour React Navigation Advanced",
"slug": "detour-react-navigation-advanced",
"version": "1.0.0",
"orientation": "portrait",
"orientation": "default",
"icon": "./assets/detour-logo.png",
"newArchEnabled": true,
"scheme": "detour-react-navigation-advanced",
"ios": {
"bundleIdentifier": "swmansion.privatemind",
"bundleIdentifier": "detourreactnative.reactnavigationadvanced",
"supportsTablet": true,
"icon": "./assets/detour-logo.png",
"associatedDomains": ["applinks:privatemind.godetour.link"]
"associatedDomains": ["applinks:<your-org>.godetour.link"]
},
"android": {
"package": "swmansion.privatemind",
"package": "detourreactnative.reactnavigationadvanced",
"adaptiveIcon": {
"foregroundImage": "./assets/detour-logo.png",
"backgroundColor": "#0C1221"
Expand All @@ -27,8 +27,8 @@
"data": [
{
"scheme": "https",
"host": "privatemind.godetour.link",
"pathPrefix": "/SneWQjYDGD"
"host": "<your-org>.godetour.link",
"pathPrefix": "/<your-app-hash>"
}
],
"category": ["BROWSABLE", "DEFAULT"]
Expand Down
13 changes: 13 additions & 0 deletions examples/react-navigation-advanced/metro.config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
const { getDefaultConfig } = require("@expo/metro-config");
const path = require("path");

const config = getDefaultConfig(__dirname);
const monorepoRoot = path.resolve(__dirname, "../..");
const appNodeModules = path.resolve(__dirname, "node_modules");
const rootNodeModules = path.resolve(monorepoRoot, "node_modules");

config.resolver.unstable_enablePackageExports = true;
config.resolver.unstable_conditionNames = ["react-native", "require", "default"];
config.resolver.nodeModulesPaths = [appNodeModules, rootNodeModules];
config.resolver.disableHierarchicalLookup = true;
config.resolver.extraNodeModules = {
...(config.resolver.extraNodeModules ?? {}),
react: path.resolve(rootNodeModules, "react"),
"react/jsx-runtime": path.resolve(rootNodeModules, "react/jsx-runtime.js"),
"react/jsx-dev-runtime": path.resolve(rootNodeModules, "react/jsx-dev-runtime.js"),
"react-native": path.resolve(rootNodeModules, "react-native"),
};

module.exports = config;
50 changes: 29 additions & 21 deletions examples/react-navigation-advanced/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo } from "react";

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

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

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

import { type Config, DetourProvider } from "@swmansion/react-native-detour";
import {
type Config,
DETOUR_LINKING_PREFIX,
Detour,
DetourProvider,
} from "@swmansion/react-native-detour";

import { AuthProvider } from "./auth";
import { Navigation, type RootStackParamList } from "./navigation";
import { Navigation, type RootStackParamList, linkingConfig } from "./navigation";
import { colors, styles } from "./styles";
import { useDetourGate } from "./useDetourGate";

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

const AppContent = ({
navigationRef,
isNavigationReady,
}: {
navigationRef: ReturnType<typeof useNavigationContainerRef<RootStackParamList>>;
isNavigationReady: boolean;
}) => {
useDetourGate(navigationRef, isNavigationReady);
return <Navigation />;
};

const AppRoot = () => {
const navigationRef = useNavigationContainerRef<RootStackParamList>();
const [isNavigationReady, setNavigationReady] = useState(false);
const linking = useMemo<LinkingOptions<RootStackParamList>>(
() => ({
prefixes: [DETOUR_LINKING_PREFIX],
config: linkingConfig,
async getInitialURL() {
return await Detour.getInitialURL();
},
subscribe(listener) {
const subscription = Detour.addEventListener("url", ({ url }) => {
listener(url);
});

return () => subscription.remove();
},
}),
[],
);

return (
<NavigationContainer
ref={navigationRef}
onReady={() => setNavigationReady(true)}
linking={linking}
onReady={() => SplashScreen.hideAsync()}
theme={{
dark: true,
colors: {
Expand All @@ -85,7 +93,7 @@ const AppRoot = () => {
},
}}
>
<AppContent navigationRef={navigationRef} isNavigationReady={isNavigationReady} />
<Navigation />
</NavigationContainer>
);
};
Expand Down
34 changes: 28 additions & 6 deletions examples/react-navigation-advanced/src/navigation/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { LinkingOptions, NavigatorScreenParams } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";

import { useAuth } from "../auth";
import { colors } from "../styles";
import { TabNavigator } from "./TabNavigator";
import { TabNavigator, type TabParamList } from "./TabNavigator";
import { Details } from "./screens/Details";
import { NotFound } from "./screens/NotFound";
import { Onboarding } from "./screens/Onboarding";
Expand All @@ -11,7 +12,7 @@ import { SignIn } from "./screens/SignIn";
export type RootStackParamList = {
SignIn: undefined;
Onboarding: undefined;
Tabs: undefined;
Tabs: NavigatorScreenParams<TabParamList> | undefined;
Details:
| {
fromDeepLink?: string;
Expand All @@ -22,14 +23,30 @@ export type RootStackParamList = {
NotFound: { path?: string } | undefined;
};

export const linkingConfig: NonNullable<LinkingOptions<RootStackParamList>["config"]> = {
screens: {
SignIn: "sign-in",
Onboarding: "onboarding",
Tabs: {
screens: {
Home: "",
Explore: "explore",
Settings: "settings",
},
},
Details: "details",
NotFound: "*",
},
};

const Stack = createNativeStackNavigator<RootStackParamList>();

const screenOptions = {
headerShown: false,
contentStyle: { backgroundColor: colors.background },
};

function AuthScreens({
function renderAuthScreens({
isSignedIn,
isOnboardingCompleted,
}: {
Expand All @@ -51,16 +68,21 @@ function AuthScreens({
}

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

if (!isLoaded) return null;

return (
<Stack.Navigator screenOptions={screenOptions}>
<AuthScreens isSignedIn={isSignedIn} isOnboardingCompleted={isOnboardingCompleted} />
<Stack.Navigator
screenOptions={screenOptions}
UNSTABLE_routeNamesChangeBehavior="lastUnhandled"
>
{renderAuthScreens({ isSignedIn, isOnboardingCompleted })}
<Stack.Screen name="NotFound" component={NotFound} />
</Stack.Navigator>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ export function Explore() {

<Text style={styles.sectionHeader}>Deferred Link + Auth Gate</Text>
<Text style={styles.bullet}>
Copy a Detour link, sign out, then relaunch. The link survives sign-in —{" "}
<Text style={styles.accent}>useDetourGate</Text> picks it up once authenticated.
Copy a Detour link, sign out, then relaunch. The link survives the sign-in flow — you'll
land on the Details screen once you complete sign-in and onboarding.
</Text>
<Text style={styles.bullet}>
Make sure <Text style={styles.accent}>Copy link feature enabled</Text> is turned on in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ export function Home() {

<Text style={styles.sectionHeader}>Deferred Link + Auth Gate</Text>
<Text style={styles.bullet}>
Copy a Detour link, sign out, then relaunch. The link survives sign-in —{" "}
<Text style={styles.accent}>useDetourGate</Text> picks it up once authenticated.
Copy a Detour link, sign out, then relaunch. The link survives the sign-in flow — you'll
land on the Details screen once you complete sign-in and onboarding.
</Text>
<Text style={styles.bullet}>
Make sure <Text style={styles.accent}>Copy link feature enabled</Text> is turned on in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,8 @@ export function Onboarding() {
<Text style={styles.sectionHeader}>Deferred Link + Auth Gate</Text>
<Text style={styles.bullet}>
Copy a Detour link to your clipboard, sign out, then relaunch. The deferred link will
survive the sign-in flow — once you authenticate,{" "}
<Text style={styles.accent}>useDetourGate</Text> picks it back up and navigates
automatically.
survive the sign-in flow — once you finish sign-in and onboarding, React Navigation
replays the link and lands you on Details automatically.
</Text>
<Text style={styles.bullet}>
Make sure <Text style={styles.accent}>Copy link feature enabled</Text> is turned on in App
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ export function Settings() {

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