diff --git a/README.md b/README.md
index 0da5d6f..3b395b1 100644
--- a/README.md
+++ b/README.md
@@ -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
+
+ {isSignedIn
+ ? isOnboardingCompleted
+ ? <>
+
+
+ >
+ :
+ : }
+
+```
+
+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:
@@ -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:
@@ -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
+Detour.addEventListener("url", (event: DetourUrlEvent) => void): DetourUrlSubscription
+```
+
---
## License
@@ -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).
-[](https://swmansion.com)
+[](https://swmansion.com)
diff --git a/examples/react-navigation-advanced/README.md b/examples/react-navigation-advanced/README.md
index 5eb8728..17933db 100644
--- a/examples/react-navigation-advanced/README.md
+++ b/examples/react-navigation-advanced/README.md
@@ -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
diff --git a/examples/react-navigation-advanced/app.json b/examples/react-navigation-advanced/app.json
index 7fc807a..40bb7f0 100644
--- a/examples/react-navigation-advanced/app.json
+++ b/examples/react-navigation-advanced/app.json
@@ -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:.godetour.link"]
},
"android": {
- "package": "swmansion.privatemind",
+ "package": "detourreactnative.reactnavigationadvanced",
"adaptiveIcon": {
"foregroundImage": "./assets/detour-logo.png",
"backgroundColor": "#0C1221"
@@ -27,8 +27,8 @@
"data": [
{
"scheme": "https",
- "host": "privatemind.godetour.link",
- "pathPrefix": "/SneWQjYDGD"
+ "host": ".godetour.link",
+ "pathPrefix": "/"
}
],
"category": ["BROWSABLE", "DEFAULT"]
diff --git a/examples/react-navigation-advanced/metro.config.js b/examples/react-navigation-advanced/metro.config.js
index b1c39c5..1a9f126 100644
--- a/examples/react-navigation-advanced/metro.config.js
+++ b/examples/react-navigation-advanced/metro.config.js
@@ -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;
diff --git a/examples/react-navigation-advanced/src/App.tsx b/examples/react-navigation-advanced/src/App.tsx
index 71e542c..31ab1d0 100644
--- a/examples/react-navigation-advanced/src/App.tsx
+++ b/examples/react-navigation-advanced/src/App.tsx
@@ -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;
@@ -48,25 +52,29 @@ export const detourConfig: Config = {
SplashScreen.preventAutoHideAsync();
SystemUI.setBackgroundColorAsync(colors.background);
-const AppContent = ({
- navigationRef,
- isNavigationReady,
-}: {
- navigationRef: ReturnType>;
- isNavigationReady: boolean;
-}) => {
- useDetourGate(navigationRef, isNavigationReady);
- return ;
-};
-
const AppRoot = () => {
- const navigationRef = useNavigationContainerRef();
- const [isNavigationReady, setNavigationReady] = useState(false);
+ const linking = useMemo>(
+ () => ({
+ 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 (
setNavigationReady(true)}
+ linking={linking}
+ onReady={() => SplashScreen.hideAsync()}
theme={{
dark: true,
colors: {
@@ -85,7 +93,7 @@ const AppRoot = () => {
},
}}
>
-
+
);
};
diff --git a/examples/react-navigation-advanced/src/navigation/index.tsx b/examples/react-navigation-advanced/src/navigation/index.tsx
index 333b9c4..4ef6ce3 100644
--- a/examples/react-navigation-advanced/src/navigation/index.tsx
+++ b/examples/react-navigation-advanced/src/navigation/index.tsx
@@ -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";
@@ -11,7 +12,7 @@ import { SignIn } from "./screens/SignIn";
export type RootStackParamList = {
SignIn: undefined;
Onboarding: undefined;
- Tabs: undefined;
+ Tabs: NavigatorScreenParams | undefined;
Details:
| {
fromDeepLink?: string;
@@ -22,6 +23,22 @@ export type RootStackParamList = {
NotFound: { path?: string } | undefined;
};
+export const linkingConfig: NonNullable["config"]> = {
+ screens: {
+ SignIn: "sign-in",
+ Onboarding: "onboarding",
+ Tabs: {
+ screens: {
+ Home: "",
+ Explore: "explore",
+ Settings: "settings",
+ },
+ },
+ Details: "details",
+ NotFound: "*",
+ },
+};
+
const Stack = createNativeStackNavigator();
const screenOptions = {
@@ -29,7 +46,7 @@ const screenOptions = {
contentStyle: { backgroundColor: colors.background },
};
-function AuthScreens({
+function renderAuthScreens({
isSignedIn,
isOnboardingCompleted,
}: {
@@ -51,7 +68,9 @@ 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();
@@ -59,8 +78,11 @@ export function Navigation() {
if (!isLoaded) return null;
return (
-
-
+
+ {renderAuthScreens({ isSignedIn, isOnboardingCompleted })}
);
diff --git a/examples/react-navigation-advanced/src/navigation/screens/Explore.tsx b/examples/react-navigation-advanced/src/navigation/screens/Explore.tsx
index d6e5e4a..48b1c11 100644
--- a/examples/react-navigation-advanced/src/navigation/screens/Explore.tsx
+++ b/examples/react-navigation-advanced/src/navigation/screens/Explore.tsx
@@ -43,8 +43,8 @@ export function Explore() {
Deferred Link + Auth Gate
- Copy a Detour link, sign out, then relaunch. The link survives sign-in —{" "}
- useDetourGate 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.
Make sure Copy link feature enabled is turned on in
diff --git a/examples/react-navigation-advanced/src/navigation/screens/Home.tsx b/examples/react-navigation-advanced/src/navigation/screens/Home.tsx
index 5b264ad..3c5f144 100644
--- a/examples/react-navigation-advanced/src/navigation/screens/Home.tsx
+++ b/examples/react-navigation-advanced/src/navigation/screens/Home.tsx
@@ -43,8 +43,8 @@ export function Home() {
Deferred Link + Auth Gate
- Copy a Detour link, sign out, then relaunch. The link survives sign-in —{" "}
- useDetourGate 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.
Make sure Copy link feature enabled is turned on in
diff --git a/examples/react-navigation-advanced/src/navigation/screens/Onboarding.tsx b/examples/react-navigation-advanced/src/navigation/screens/Onboarding.tsx
index f7a5061..574c00c 100644
--- a/examples/react-navigation-advanced/src/navigation/screens/Onboarding.tsx
+++ b/examples/react-navigation-advanced/src/navigation/screens/Onboarding.tsx
@@ -44,9 +44,8 @@ export function Onboarding() {
Deferred Link + Auth Gate
Copy a Detour link to your clipboard, sign out, then relaunch. The deferred link will
- survive the sign-in flow — once you authenticate,{" "}
- useDetourGate 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.
Make sure Copy link feature enabled is turned on in App
diff --git a/examples/react-navigation-advanced/src/navigation/screens/Settings.tsx b/examples/react-navigation-advanced/src/navigation/screens/Settings.tsx
index 45960f2..6c90b29 100644
--- a/examples/react-navigation-advanced/src/navigation/screens/Settings.tsx
+++ b/examples/react-navigation-advanced/src/navigation/screens/Settings.tsx
@@ -43,8 +43,8 @@ export function Settings() {
Deferred Link + Auth Gate
- Copy a Detour link, sign out, then relaunch. The link survives sign-in —{" "}
- useDetourGate 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.
Make sure Copy link feature enabled is turned on in
diff --git a/examples/react-navigation-advanced/src/useDetourGate.ts b/examples/react-navigation-advanced/src/useDetourGate.ts
deleted file mode 100644
index 603316d..0000000
--- a/examples/react-navigation-advanced/src/useDetourGate.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { useEffect } from "react";
-
-import * as SplashScreen from "expo-splash-screen";
-
-import type { NavigationContainerRefWithCurrent } from "@react-navigation/native";
-
-import { useDetourContext } from "@swmansion/react-native-detour";
-
-import { useAuth } from "./auth";
-import type { RootStackParamList } from "./navigation";
-
-// Coordinates incoming Detour links with the app's auth state.
-// Navigation's conditional rendering handles all auth-based screen routing
-// (SignIn → Onboarding → Tabs). This hook only drives two things:
-// 1. Hiding the splash screen once auth and link processing are ready.
-// 2. Navigating to the link destination once signed in and onboarded.
-//
-// Flow:
-// not loaded / nav not ready → wait
-// not signed in → hide splash (Navigation shows SignIn)
-// signed in + link + no onboarding → hide splash, keep link alive
-// (Navigation shows Onboarding; re-fires after it's done)
-// signed in + link + onboarded → clearLink, navigate to matched screen or NotFound
-// signed in + no link → hide splash (Navigation shows correct screen)
-export const useDetourGate = (
- navigationRef: NavigationContainerRefWithCurrent,
- isNavigationReady: boolean,
-) => {
- const { isLinkProcessed, link, clearLink } = useDetourContext();
- const { isLoaded, isSignedIn, isOnboardingCompleted } = useAuth();
-
- useEffect(() => {
- if (!isNavigationReady || !isLinkProcessed || !isLoaded) return;
-
- if (!isSignedIn) {
- SplashScreen.hideAsync();
- return;
- }
-
- if (link) {
- // Onboarding must run once before the deep link destination is shown.
- // Keep the link alive so this branch re-fires after onboarding completes.
- if (!isOnboardingCompleted) {
- SplashScreen.hideAsync();
- return;
- }
-
- clearLink();
- SplashScreen.hideAsync();
-
- // apply your custom mapping here from link.pathname to your navigation structure. The example links are designed to match the navigation structure in this example app, but your mapping may differ based on how you set up your navigation and what your link paths look like.
- if (link.pathname === "/details") {
- navigationRef.navigate("Details", {
- fromDeepLink: "true",
- linkType: link.type,
- ...link.params,
- });
- } else {
- navigationRef.navigate("NotFound", { path: link.pathname });
- }
- return;
- }
-
- // No link: hide splash, Navigation shows the correct screen via conditional rendering.
- SplashScreen.hideAsync();
- }, [
- isNavigationReady,
- isLinkProcessed,
- isLoaded,
- isSignedIn,
- isOnboardingCompleted,
- link,
- clearLink,
- navigationRef,
- ]);
-};
diff --git a/examples/react-navigation/README.md b/examples/react-navigation/README.md
index b3bf179..af4f4bf 100644
--- a/examples/react-navigation/README.md
+++ b/examples/react-navigation/README.md
@@ -5,13 +5,18 @@ This example demonstrates the minimal integration of `@swmansion/react-native-de
## Scenario represented
- Navigation stack: `Home` and `Details`.
-- `Home` reads `linkRoute` from Detour context.
-- When route resolves to `/details`, app navigates to `Details` and calls `clearLink()`.
+- App is configured with React Navigation linking and Detour as the deep-link source.
## Deep link handling model
-- Detour handles deferred/verified links and exposes resolved route via `useDetourContext`.
-- App maps `linkRoute` to React Navigation route (`/details` -> `Details`).
+- Links are resolved by Detour and passed to React Navigation's linking flow.
+- Both app-start links and links opened while the app is running are handled.
+- Route mapping is defined in the linking config (`Details: "details"`, `NotFound: "*"`) so screens are resolved declaratively.
+- For React Navigation linking details, see:
+ https://reactnavigation.org/docs/deep-linking?config=static#integrating-with-other-tools
+
+For an auth-gated setup that uses React Navigation's pending-link behavior to survive sign-in and
+onboarding, see `examples/react-navigation-advanced`.
## Test flow
diff --git a/examples/react-navigation/metro.config.js b/examples/react-navigation/metro.config.js
index b1c39c5..1a9f126 100644
--- a/examples/react-navigation/metro.config.js
+++ b/examples/react-navigation/metro.config.js
@@ -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;
diff --git a/examples/react-navigation/src/App.tsx b/examples/react-navigation/src/App.tsx
index 34ddadc..1fa25f0 100644
--- a/examples/react-navigation/src/App.tsx
+++ b/examples/react-navigation/src/App.tsx
@@ -1,14 +1,19 @@
-import { useEffect, useState } from "react";
+import { useEffect, useMemo } from "react";
import { Text, View } from "react-native";
import * as SplashScreen from "expo-splash-screen";
-import { NavigationContainer, useNavigationContainerRef } from "@react-navigation/native";
+import { type LinkingOptions, NavigationContainer } from "@react-navigation/native";
-import { type Config, DetourProvider, useDetourContext } from "@swmansion/react-native-detour";
+import {
+ type Config,
+ DETOUR_LINKING_PREFIX,
+ Detour,
+ DetourProvider,
+} from "@swmansion/react-native-detour";
-import { Navigation, type RootStackParamList } from "./navigation";
+import { Navigation, type RootStackParamList, linkingConfig } from "./navigation";
import { styles } from "./styles";
const hasCredentials =
@@ -45,49 +50,30 @@ const detourConfig: Config = {
SplashScreen.preventAutoHideAsync();
const AppNavigator = () => {
- const navigationRef = useNavigationContainerRef();
- const [isNavigationReady, setNavigationReady] = useState(false);
- const { isLinkProcessed, link, clearLink } = useDetourContext();
-
- // Handle Detour resolved links.
- useEffect(() => {
- if (!isNavigationReady || !isLinkProcessed || !link) {
- return;
- }
-
- clearLink();
-
- // In this example, we only handle one specific link that resolves to the details screen with Detour to demonstrate the flow.
- // In a real app, you would likely have a more comprehensive mapping of Detour-resolved routes to in-app navigation targets.
- if (link.pathname === "/details") {
- navigationRef.navigate("Details", {
- linkParams: link.params,
- // Add deep link metadata to demonstrate route propagation.
- fromDeepLink: true,
- linkType: link.type,
- });
- } else {
- navigationRef.navigate("NotFound", {
- path: link.pathname,
- params: link.params,
- });
- }
- }, [clearLink, isLinkProcessed, isNavigationReady, link, navigationRef]);
-
- // Hide the splash screen once the initial link is processed (or determined to be absent).
- useEffect(() => {
- if (isLinkProcessed && isNavigationReady) {
- SplashScreen.hideAsync();
- }
- }, [isLinkProcessed, isNavigationReady]);
-
- // While the initial link is being processed, we don't want to render the app
- if (!isLinkProcessed) {
- return null;
- }
+ const linking = useMemo>(
+ () => ({
+ 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 (
- setNavigationReady(true)}>
+ SplashScreen.hideAsync()}
+ >
);
diff --git a/examples/react-navigation/src/navigation/index.tsx b/examples/react-navigation/src/navigation/index.tsx
index d550678..a9a792d 100644
--- a/examples/react-navigation/src/navigation/index.tsx
+++ b/examples/react-navigation/src/navigation/index.tsx
@@ -1,5 +1,6 @@
import { Image } from "react-native";
+import type { LinkingOptions } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { colors } from "../styles";
@@ -11,12 +12,20 @@ export type RootStackParamList = {
Home: undefined;
Details:
| {
- fromDeepLink?: boolean;
+ fromDeepLink?: string;
linkType?: string;
- linkParams?: Record;
+ [key: string]: string | undefined;
}
| undefined;
- NotFound: { path?: string; params?: Record } | undefined;
+ NotFound: { path?: string } | undefined;
+};
+
+export const linkingConfig: NonNullable["config"]> = {
+ screens: {
+ Home: "",
+ Details: "details",
+ NotFound: "*",
+ },
};
const Stack = createNativeStackNavigator();
diff --git a/examples/react-navigation/src/navigation/screens/Details.tsx b/examples/react-navigation/src/navigation/screens/Details.tsx
index 5ee4f2a..5fda4dc 100644
--- a/examples/react-navigation/src/navigation/screens/Details.tsx
+++ b/examples/react-navigation/src/navigation/screens/Details.tsx
@@ -24,10 +24,13 @@ export function Details() {
const navigation = useNavigation>();
const route = useRoute>();
const insets = useSafeAreaInsets();
- const fromDeepLink = route.params?.fromDeepLink;
- const linkType = route.params?.linkType;
- const linkParams = route.params?.linkParams;
- const hasLinkParams = linkParams && Object.keys(linkParams).length > 0;
+ const params = route.params ?? {};
+ const fromDeepLink = params.fromDeepLink === "true";
+ const linkType = params.linkType;
+ const linkParams = Object.fromEntries(
+ Object.entries(params).filter(([key]) => key !== "fromDeepLink" && key !== "linkType"),
+ );
+ const hasLinkParams = Object.keys(linkParams).length > 0;
const goBack = () => (navigation.canGoBack() ? navigation.goBack() : navigation.navigate("Home"));
diff --git a/examples/react-navigation/src/navigation/screens/NotFound.tsx b/examples/react-navigation/src/navigation/screens/NotFound.tsx
index 11fe39e..464df84 100644
--- a/examples/react-navigation/src/navigation/screens/NotFound.tsx
+++ b/examples/react-navigation/src/navigation/screens/NotFound.tsx
@@ -11,8 +11,6 @@ export function NotFound() {
const navigation = useNavigation>();
const route = useRoute>();
const path = route.params?.path;
- const params = route.params?.params;
- const hasParams = params && Object.keys(params).length > 0;
return (
@@ -31,13 +29,6 @@ export function NotFound() {
>
)}
- {hasParams &&
- Object.entries(params).map(([key, value]) => (
-
- {key}: {value}
-
- ))}
-
{
const params: Record = {};
- searchParams.forEach((value, key) => {
+ for (const [key, value] of searchParams) {
params[key] = value;
- });
+ }
return params;
}
@@ -72,7 +83,7 @@ export const useDetour = ({
}
try {
- const urlObj = new URL(rawLink);
+ const urlObj = new URL(rawLink) as ParsedUrl;
const isWeb = isWebUrl(rawLink, urlObj);
@@ -166,6 +177,7 @@ export const useDetour = ({
const subscription = Linking.addEventListener("url", async ({ url }) => {
const resolved = await resolveLink({ rawLink: url });
if (resolved) {
+ notifyReactNavigationUrl(resolved);
setLink(resolved);
}
});
@@ -174,10 +186,15 @@ export const useDetour = ({
// 2. Handle Cold Start (Universal vs Deferred)
useEffect(() => {
- if (!apiKey || !appID) return;
+ if (!apiKey || !appID) {
+ markReactNavigationInitialUrlProcessed();
+ setProcessed(true);
+ return;
+ }
(async () => {
if (sessionHandled) {
+ markReactNavigationInitialUrlProcessed();
setProcessed(true);
return;
}
@@ -191,6 +208,7 @@ export const useDetour = ({
await markFirstEntrance(storage);
const resolved = await resolveLink({ rawLink: initialUrl });
if (resolved) {
+ notifyReactNavigationUrl(resolved);
setLink(resolved);
}
return;
@@ -211,11 +229,15 @@ export const useDetour = ({
if (apiLink) {
const resolved = await resolveLink({ rawLink: apiLink, typeOverride: "deferred" });
- if (resolved) setLink(resolved);
+ if (resolved) {
+ notifyReactNavigationUrl(resolved);
+ setLink(resolved);
+ }
}
} catch (error) {
console.error("🔗[Detour:ERROR]", error);
} finally {
+ markReactNavigationInitialUrlProcessed();
setProcessed(true);
}
})();
diff --git a/packages/react-native-detour/src/links/types/index.ts b/packages/react-native-detour/src/links/types/index.ts
index 21eefb4..f254ad0 100644
--- a/packages/react-native-detour/src/links/types/index.ts
+++ b/packages/react-native-detour/src/links/types/index.ts
@@ -45,3 +45,11 @@ export interface DetourStorage {
setItem(key: string, value: string): Promise | void;
removeItem?(key: string): Promise | void;
}
+
+export type DetourUrlEvent = {
+ url: string;
+};
+
+export type DetourUrlSubscription = {
+ remove: () => void;
+};
diff --git a/packages/react-native-detour/src/links/utils/reactNavigation.ts b/packages/react-native-detour/src/links/utils/reactNavigation.ts
new file mode 100644
index 0000000..54e5dbb
--- /dev/null
+++ b/packages/react-native-detour/src/links/utils/reactNavigation.ts
@@ -0,0 +1,91 @@
+import type { DetourLink, DetourUrlEvent, DetourUrlSubscription, LinkType } from "../types/index";
+
+export const DETOUR_LINKING_PREFIX = "detour://";
+
+type DetourUrlListener = (event: DetourUrlEvent) => void;
+
+const listeners = new Set();
+
+let initialProcessingFinished = false;
+let pendingInitialUrl: string | undefined;
+const initialUrlWaiters: Array<(url: string | undefined) => void> = [];
+
+const buildReactNavigationUrl = (route: string, linkType: LinkType) => {
+ const normalizedRoute = route.startsWith("/") ? route : `/${route}`;
+ const [pathname = "/", ...searchParts] = normalizedRoute.split("?");
+ const search = searchParts.join("?");
+ const params = new URLSearchParams(search);
+
+ params.append("fromDeepLink", "true");
+ params.append("linkType", linkType);
+
+ const query = params.toString();
+ const path = pathname.replace(/^\//, "");
+
+ return `${DETOUR_LINKING_PREFIX}${path}${query ? `?${query}` : ""}`;
+};
+
+const emitUrlEvent = (url: string) => {
+ for (const listener of listeners) {
+ listener({ url });
+ }
+};
+
+export const notifyReactNavigationUrl = (link: Exclude) => {
+ const url = buildReactNavigationUrl(link.route, link.type);
+
+ if (!initialProcessingFinished) {
+ if (pendingInitialUrl === undefined || listeners.size === 0) {
+ pendingInitialUrl = url;
+ return;
+ }
+ }
+
+ emitUrlEvent(url);
+};
+
+export const markReactNavigationInitialUrlProcessed = () => {
+ if (initialProcessingFinished) {
+ return;
+ }
+
+ initialProcessingFinished = true;
+ if (initialUrlWaiters.length === 0) {
+ return;
+ }
+
+ const url = consumePendingInitialUrl();
+ for (const resolve of initialUrlWaiters) {
+ resolve(url);
+ }
+ initialUrlWaiters.length = 0;
+};
+
+const consumePendingInitialUrl = () => {
+ const url = pendingInitialUrl;
+ pendingInitialUrl = undefined;
+ return url;
+};
+
+export const getReactNavigationInitialUrl = async (): Promise => {
+ if (initialProcessingFinished) {
+ return consumePendingInitialUrl();
+ }
+
+ return await new Promise((resolve) => {
+ initialUrlWaiters.push(resolve);
+ });
+};
+
+export const addReactNavigationEventListener = (
+ _event: "url",
+ listener: DetourUrlListener,
+): DetourUrlSubscription => {
+ listeners.add(listener);
+
+ return {
+ remove: () => {
+ listeners.delete(listener);
+ },
+ };
+};
diff --git a/packages/react-native-detour/src/reactNavigation.ts b/packages/react-native-detour/src/reactNavigation.ts
new file mode 100644
index 0000000..4c844a0
--- /dev/null
+++ b/packages/react-native-detour/src/reactNavigation.ts
@@ -0,0 +1,23 @@
+import type { DetourUrlEvent, DetourUrlSubscription } from "./links/types/index";
+import {
+ DETOUR_LINKING_PREFIX,
+ addReactNavigationEventListener,
+ getReactNavigationInitialUrl,
+} from "./links/utils/reactNavigation";
+
+export type { DetourUrlEvent, DetourUrlSubscription };
+
+type DetourReactNavigationApi = {
+ getInitialURL: () => Promise;
+ addEventListener: (
+ event: "url",
+ listener: (event: DetourUrlEvent) => void,
+ ) => DetourUrlSubscription;
+};
+
+export const Detour: DetourReactNavigationApi = {
+ getInitialURL: getReactNavigationInitialUrl,
+ addEventListener: addReactNavigationEventListener,
+};
+
+export { DETOUR_LINKING_PREFIX };