From 3be10274999bbd3811ea2f2af9ae02316079568c Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Mon, 11 May 2026 16:44:55 +0200 Subject: [PATCH 1/9] feat: integrate detour with react navigation linking --- README.md | 2 +- examples/react-navigation-advanced/README.md | 4 +- .../react-navigation-advanced/src/App.tsx | 64 +++++++++++++++---- .../src/navigation/index.tsx | 21 +++++- .../src/useDetourGate.ts | 39 +++++------ 5 files changed, 93 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 0da5d6f..df49c29 100644 --- a/README.md +++ b/README.md @@ -287,4 +287,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) diff --git a/examples/react-navigation-advanced/README.md b/examples/react-navigation-advanced/README.md index 5eb8728..6fed352 100644 --- a/examples/react-navigation-advanced/README.md +++ b/examples/react-navigation-advanced/README.md @@ -7,13 +7,13 @@ 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`. +- Detour processes all link types (universal / app links, custom scheme, and deferred). Once auth + onboarding gates are passed, the example forwards resolved URLs into React Navigation's `linking` integration so path mapping is handled by navigation 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`). +- After onboarding, `useDetourGate` re-fires again, clears the link, and forwards the URL to React Navigation linking, which resolves `details` (or falls through to `NotFound`). ## Test flow diff --git a/examples/react-navigation-advanced/src/App.tsx b/examples/react-navigation-advanced/src/App.tsx index 71e542c..3dbae69 100644 --- a/examples/react-navigation-advanced/src/App.tsx +++ b/examples/react-navigation-advanced/src/App.tsx @@ -1,16 +1,20 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } 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, + useNavigationContainerRef, +} from "@react-navigation/native"; import { type Config, 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"; @@ -48,24 +52,56 @@ export const detourConfig: Config = { SplashScreen.preventAutoHideAsync(); SystemUI.setBackgroundColorAsync(colors.background); -const AppContent = ({ - navigationRef, - isNavigationReady, -}: { - navigationRef: ReturnType>; - isNavigationReady: boolean; -}) => { - useDetourGate(navigationRef, isNavigationReady); - return ; -}; +const DETOUR_LINKING_PREFIX = "detour-react-navigation-advanced://"; const AppRoot = () => { const navigationRef = useNavigationContainerRef(); const [isNavigationReady, setNavigationReady] = useState(false); + const detourListenersRef = useRef(new Set<(url: string) => void>()); + const queuedInitialUrlRef = useRef(undefined); + + const consumeQueuedUrl = useCallback(() => { + const queuedUrl = queuedInitialUrlRef.current; + queuedInitialUrlRef.current = undefined; + return queuedUrl; + }, []); + + const emitDetourUrl = useCallback((url: string) => { + queuedInitialUrlRef.current = url; + detourListenersRef.current.forEach((listener) => listener(url)); + }, []); + + // React Navigation deep-link integration with an external source: + // https://reactnavigation.org/docs/deep-linking?config=static#integrating-with-other-tools + const linking = useMemo>( + () => ({ + prefixes: [DETOUR_LINKING_PREFIX], + config: linkingConfig, + async getInitialURL() { + return consumeQueuedUrl(); + }, + subscribe(listener) { + detourListenersRef.current.add(listener); + + const queuedUrl = consumeQueuedUrl(); + if (queuedUrl) { + listener(queuedUrl); + } + + return () => { + detourListenersRef.current.delete(listener); + }; + }, + }), + [consumeQueuedUrl], + ); + + useDetourGate(isNavigationReady, emitDetourUrl); return ( setNavigationReady(true)} theme={{ dark: true, @@ -85,7 +121,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..9716416 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 = { diff --git a/examples/react-navigation-advanced/src/useDetourGate.ts b/examples/react-navigation-advanced/src/useDetourGate.ts index 603316d..cf25a9f 100644 --- a/examples/react-navigation-advanced/src/useDetourGate.ts +++ b/examples/react-navigation-advanced/src/useDetourGate.ts @@ -2,29 +2,42 @@ 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"; + +const DETOUR_SCHEME = "detour-react-navigation-advanced://"; + +const toNavigationUrl = (route: string, linkType: string) => { + 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_SCHEME}${path}${query ? `?${query}` : ""}`; +}; // 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. +// 2. Forwarding the link URL to React Navigation 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 + link + onboarded → clearLink, forward URL to React Navigation linking // signed in + no link → hide splash (Navigation shows correct screen) export const useDetourGate = ( - navigationRef: NavigationContainerRefWithCurrent, isNavigationReady: boolean, + handleDetourLink: (url: string) => void, ) => { const { isLinkProcessed, link, clearLink } = useDetourContext(); const { isLoaded, isSignedIn, isOnboardingCompleted } = useAuth(); @@ -46,18 +59,8 @@ export const useDetourGate = ( } clearLink(); + handleDetourLink(toNavigationUrl(link.route, link.type)); 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; } @@ -71,6 +74,6 @@ export const useDetourGate = ( isOnboardingCompleted, link, clearLink, - navigationRef, + handleDetourLink, ]); }; From 20968000ff3f0fd881e9fc793d97c8991b338ca7 Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Wed, 13 May 2026 12:09:59 +0200 Subject: [PATCH 2/9] feat: document react navigation linking integration --- README.md | 47 ++++++++++ examples/react-navigation-advanced/README.md | 14 ++- .../react-navigation-advanced/src/App.tsx | 57 ++---------- .../src/detourLinking.ts | 77 ++++++++++++++++ .../src/useDetourGate.ts | 77 ++-------------- examples/react-navigation/README.md | 10 +- examples/react-navigation/src/App.tsx | 77 +++++++--------- .../react-navigation/src/navigation/index.tsx | 15 ++- .../src/navigation/screens/Details.tsx | 11 ++- .../src/navigation/screens/NotFound.tsx | 9 -- packages/react-native-detour/src/index.ts | 3 + .../src/links/hooks/useDetour.ts | 32 ++++++- .../src/links/types/index.ts | 8 ++ .../src/links/utils/reactNavigation.ts | 91 +++++++++++++++++++ .../src/reactNavigation.ts | 23 +++++ 15 files changed, 357 insertions(+), 194 deletions(-) create mode 100644 examples/react-navigation-advanced/src/detourLinking.ts create mode 100644 packages/react-native-detour/src/links/utils/reactNavigation.ts create mode 100644 packages/react-native-detour/src/reactNavigation.ts diff --git a/README.md b/README.md index df49c29..2212ba2 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,34 @@ 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 + ### Controlling which links Detour processes Use `linkProcessingMode` to control which link sources the SDK listens to: @@ -277,6 +305,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 diff --git a/examples/react-navigation-advanced/README.md b/examples/react-navigation-advanced/README.md index 6fed352..85fec3c 100644 --- a/examples/react-navigation-advanced/README.md +++ b/examples/react-navigation-advanced/README.md @@ -6,14 +6,20 @@ 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). Once auth + onboarding gates are passed, the example forwards resolved URLs into React Navigation's `linking` integration so path mapping is handled by navigation config. +- `useDetourGate` coordinates auth/onboarding state with the linking bridge so deferred links survive the full sign-in and onboarding flow. +- React Navigation linking uses the SDK adapter API: + - `Detour.getInitialURL()` + - `Detour.addEventListener("url", ({ url }) => ...)` +- 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. +- If a deferred link arrives and the user is not signed in, the splash hides and `SignIn` is shown. The link is queued. - 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 forwards the URL to React Navigation linking, which resolves `details` (or falls through to `NotFound`). +- After onboarding, `useDetourGate` re-fires again and the queued URL is delivered to React Navigation linking, which resolves `details` (or falls through to `NotFound`). + +Reference docs: +https://reactnavigation.org/docs/deep-linking?config=static#integrating-with-other-tools ## Test flow diff --git a/examples/react-navigation-advanced/src/App.tsx b/examples/react-navigation-advanced/src/App.tsx index 3dbae69..fa3cf16 100644 --- a/examples/react-navigation-advanced/src/App.tsx +++ b/examples/react-navigation-advanced/src/App.tsx @@ -1,20 +1,17 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { Text, View } from "react-native"; import * as SplashScreen from "expo-splash-screen"; import * as SystemUI from "expo-system-ui"; -import { - type LinkingOptions, - NavigationContainer, - useNavigationContainerRef, -} from "@react-navigation/native"; +import { NavigationContainer } from "@react-navigation/native"; import { type Config, DetourProvider } from "@swmansion/react-native-detour"; import { AuthProvider } from "./auth"; -import { Navigation, type RootStackParamList, linkingConfig } from "./navigation"; +import { useDetourLinkingBridge } from "./detourLinking"; +import { Navigation } from "./navigation"; import { colors, styles } from "./styles"; import { useDetourGate } from "./useDetourGate"; @@ -52,55 +49,13 @@ export const detourConfig: Config = { SplashScreen.preventAutoHideAsync(); SystemUI.setBackgroundColorAsync(colors.background); -const DETOUR_LINKING_PREFIX = "detour-react-navigation-advanced://"; - const AppRoot = () => { - const navigationRef = useNavigationContainerRef(); const [isNavigationReady, setNavigationReady] = useState(false); - const detourListenersRef = useRef(new Set<(url: string) => void>()); - const queuedInitialUrlRef = useRef(undefined); - - const consumeQueuedUrl = useCallback(() => { - const queuedUrl = queuedInitialUrlRef.current; - queuedInitialUrlRef.current = undefined; - return queuedUrl; - }, []); - - const emitDetourUrl = useCallback((url: string) => { - queuedInitialUrlRef.current = url; - detourListenersRef.current.forEach((listener) => listener(url)); - }, []); - - // React Navigation deep-link integration with an external source: - // https://reactnavigation.org/docs/deep-linking?config=static#integrating-with-other-tools - const linking = useMemo>( - () => ({ - prefixes: [DETOUR_LINKING_PREFIX], - config: linkingConfig, - async getInitialURL() { - return consumeQueuedUrl(); - }, - subscribe(listener) { - detourListenersRef.current.add(listener); - - const queuedUrl = consumeQueuedUrl(); - if (queuedUrl) { - listener(queuedUrl); - } - - return () => { - detourListenersRef.current.delete(listener); - }; - }, - }), - [consumeQueuedUrl], - ); - - useDetourGate(isNavigationReady, emitDetourUrl); + const { canHandleDetourLink } = useDetourGate(isNavigationReady); + const { linking } = useDetourLinkingBridge(canHandleDetourLink); return ( setNavigationReady(true)} theme={{ diff --git a/examples/react-navigation-advanced/src/detourLinking.ts b/examples/react-navigation-advanced/src/detourLinking.ts new file mode 100644 index 0000000..17c337b --- /dev/null +++ b/examples/react-navigation-advanced/src/detourLinking.ts @@ -0,0 +1,77 @@ +import { useEffect, useMemo, useRef } from "react"; + +import type { LinkingOptions } from "@react-navigation/native"; + +import { DETOUR_LINKING_PREFIX, Detour } from "@swmansion/react-native-detour"; + +import { type RootStackParamList, linkingConfig } from "./navigation"; + +export const useDetourLinkingBridge = (canHandleDetourLink: boolean) => { + const listenerRef = useRef<((url: string) => void) | undefined>(undefined); + const queuedUrlRef = useRef(undefined); + const canHandleRef = useRef(canHandleDetourLink); + canHandleRef.current = canHandleDetourLink; + + const emitOrQueue = (url: string) => { + if (canHandleRef.current && listenerRef.current) { + listenerRef.current(url); + return; + } + + // Keep only the latest pending link while auth gate is closed. + queuedUrlRef.current = url; + }; + + useEffect(() => { + if (!canHandleDetourLink || !listenerRef.current || !queuedUrlRef.current) { + return; + } + + const queuedUrl = queuedUrlRef.current; + queuedUrlRef.current = undefined; + listenerRef.current(queuedUrl); + }, [canHandleDetourLink]); + + const linking = useMemo>( + () => ({ + prefixes: [DETOUR_LINKING_PREFIX], + config: linkingConfig, + async getInitialURL() { + const url = await Detour.getInitialURL(); + if (!url) { + return undefined; + } + + if (!canHandleRef.current) { + queuedUrlRef.current = url; + return undefined; + } + + return url; + }, + subscribe(listener) { + listenerRef.current = listener; + + if (canHandleRef.current && queuedUrlRef.current) { + const queuedUrl = queuedUrlRef.current; + queuedUrlRef.current = undefined; + listener(queuedUrl); + } + + const subscription = Detour.addEventListener("url", ({ url }) => { + emitOrQueue(url); + }); + + return () => { + if (listenerRef.current === listener) { + listenerRef.current = undefined; + } + subscription.remove(); + }; + }, + }), + [], + ); + + return { linking }; +}; diff --git a/examples/react-navigation-advanced/src/useDetourGate.ts b/examples/react-navigation-advanced/src/useDetourGate.ts index cf25a9f..3b0b34a 100644 --- a/examples/react-navigation-advanced/src/useDetourGate.ts +++ b/examples/react-navigation-advanced/src/useDetourGate.ts @@ -2,78 +2,19 @@ import { useEffect } from "react"; import * as SplashScreen from "expo-splash-screen"; -import { useDetourContext } from "@swmansion/react-native-detour"; - import { useAuth } from "./auth"; -const DETOUR_SCHEME = "detour-react-navigation-advanced://"; - -const toNavigationUrl = (route: string, linkType: string) => { - 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_SCHEME}${path}${query ? `?${query}` : ""}`; -}; - -// 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. Forwarding the link URL to React Navigation 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, forward URL to React Navigation linking -// signed in + no link → hide splash (Navigation shows correct screen) -export const useDetourGate = ( - isNavigationReady: boolean, - handleDetourLink: (url: string) => void, -) => { - const { isLinkProcessed, link, clearLink } = useDetourContext(); +// Coordinates auth/onboarding gating with the React Navigation linking bridge. +// It keeps splash handling in one place and returns when deep-link navigation +// is allowed to proceed. +export const useDetourGate = (isNavigationReady: boolean) => { const { isLoaded, isSignedIn, isOnboardingCompleted } = useAuth(); + const canHandleDetourLink = isLoaded && isSignedIn && isOnboardingCompleted; 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(); - handleDetourLink(toNavigationUrl(link.route, link.type)); - SplashScreen.hideAsync(); - return; - } - - // No link: hide splash, Navigation shows the correct screen via conditional rendering. + if (!isNavigationReady || !isLoaded) return; SplashScreen.hideAsync(); - }, [ - isNavigationReady, - isLinkProcessed, - isLoaded, - isSignedIn, - isOnboardingCompleted, - link, - clearLink, - handleDetourLink, - ]); + }, [isNavigationReady, isLoaded]); + + return { canHandleDetourLink }; }; diff --git a/examples/react-navigation/README.md b/examples/react-navigation/README.md index b3bf179..1a73281 100644 --- a/examples/react-navigation/README.md +++ b/examples/react-navigation/README.md @@ -5,13 +5,15 @@ 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 uses React Navigation `linking` (`getInitialURL` + `subscribe`) to receive URLs from Detour. ## Deep link handling model -- Detour handles deferred/verified links and exposes resolved route via `useDetourContext`. -- App maps `linkRoute` to React Navigation route (`/details` -> `Details`). +- Detour exposes URL APIs for React Navigation integration: + - `Detour.getInitialURL()` + - `Detour.addEventListener("url", ({ url }) => ...)` +- Screen mapping is handled by the React Navigation linking config (`Details: "details"`, `NotFound: "*"`) instead of imperative `navigate(...)` mapping. +- Reference docs: https://reactnavigation.org/docs/deep-linking?config=static#integrating-with-other-tools ## Test flow diff --git a/examples/react-navigation/src/App.tsx b/examples/react-navigation/src/App.tsx index 34ddadc..4a3c942 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 } 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 = @@ -44,50 +49,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: LinkingOptions = { + 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(); + }; + }, +}; +const AppNavigator = () => { 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 }; From 85405249e5c2da90b4013c763a3d5cbe65ef944e Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Wed, 13 May 2026 12:26:32 +0200 Subject: [PATCH 3/9] feat: introduce useDetourReactNavigationLinking hook --- README.md | 18 ++++ examples/react-navigation-advanced/README.md | 3 +- .../react-navigation-advanced/src/App.tsx | 14 ++- .../src/detourLinking.ts | 77 ---------------- examples/react-navigation/README.md | 3 +- examples/react-navigation/src/App.tsx | 26 ++---- packages/react-native-detour/src/index.ts | 4 +- .../src/links/types/index.ts | 13 +++ .../src/reactNavigation.ts | 88 ++++++++++++++++++- 9 files changed, 139 insertions(+), 107 deletions(-) delete mode 100644 examples/react-navigation-advanced/src/detourLinking.ts diff --git a/README.md b/README.md index 2212ba2..b24443b 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,17 @@ 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, use the helper hook to avoid custom queueing boilerplate: + +```ts +import { useDetourReactNavigationLinking } from "@swmansion/react-native-detour"; + +const linking = useDetourReactNavigationLinking({ + config: linkingConfig, + canHandleUrl: isSignedIn && isOnboardingCompleted, +}); +``` + ### Controlling which links Detour processes Use `linkProcessingMode` to control which link sources the SDK listens to: @@ -317,11 +328,18 @@ export type DetourUrlEvent = { export type DetourUrlSubscription = { remove: () => void; }; + +export type UseDetourReactNavigationLinkingOptions = { + config: Config; + canHandleUrl?: boolean; + prefixes?: string[]; +}; ``` ```js Detour.getInitialURL(): Promise Detour.addEventListener("url", (event: DetourUrlEvent) => void): DetourUrlSubscription +useDetourReactNavigationLinking(options): linking ``` --- diff --git a/examples/react-navigation-advanced/README.md b/examples/react-navigation-advanced/README.md index 85fec3c..201c81f 100644 --- a/examples/react-navigation-advanced/README.md +++ b/examples/react-navigation-advanced/README.md @@ -6,10 +6,11 @@ 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 auth/onboarding state with the linking bridge so deferred links survive the full sign-in and onboarding flow. +- `useDetourGate` exposes auth/onboarding gate state and `useDetourReactNavigationLinking` handles queued URL delivery. - React Navigation linking uses the SDK adapter API: - `Detour.getInitialURL()` - `Detour.addEventListener("url", ({ url }) => ...)` +- The helper hook (`useDetourReactNavigationLinking`) wraps these APIs and removes custom bridge boilerplate. - 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 diff --git a/examples/react-navigation-advanced/src/App.tsx b/examples/react-navigation-advanced/src/App.tsx index fa3cf16..fcc54ae 100644 --- a/examples/react-navigation-advanced/src/App.tsx +++ b/examples/react-navigation-advanced/src/App.tsx @@ -7,11 +7,14 @@ import * as SystemUI from "expo-system-ui"; import { NavigationContainer } from "@react-navigation/native"; -import { type Config, DetourProvider } from "@swmansion/react-native-detour"; +import { + type Config, + DetourProvider, + useDetourReactNavigationLinking, +} from "@swmansion/react-native-detour"; import { AuthProvider } from "./auth"; -import { useDetourLinkingBridge } from "./detourLinking"; -import { Navigation } from "./navigation"; +import { Navigation, linkingConfig } from "./navigation"; import { colors, styles } from "./styles"; import { useDetourGate } from "./useDetourGate"; @@ -52,7 +55,10 @@ SystemUI.setBackgroundColorAsync(colors.background); const AppRoot = () => { const [isNavigationReady, setNavigationReady] = useState(false); const { canHandleDetourLink } = useDetourGate(isNavigationReady); - const { linking } = useDetourLinkingBridge(canHandleDetourLink); + const linking = useDetourReactNavigationLinking({ + config: linkingConfig, + canHandleUrl: canHandleDetourLink, + }); return ( { - const listenerRef = useRef<((url: string) => void) | undefined>(undefined); - const queuedUrlRef = useRef(undefined); - const canHandleRef = useRef(canHandleDetourLink); - canHandleRef.current = canHandleDetourLink; - - const emitOrQueue = (url: string) => { - if (canHandleRef.current && listenerRef.current) { - listenerRef.current(url); - return; - } - - // Keep only the latest pending link while auth gate is closed. - queuedUrlRef.current = url; - }; - - useEffect(() => { - if (!canHandleDetourLink || !listenerRef.current || !queuedUrlRef.current) { - return; - } - - const queuedUrl = queuedUrlRef.current; - queuedUrlRef.current = undefined; - listenerRef.current(queuedUrl); - }, [canHandleDetourLink]); - - const linking = useMemo>( - () => ({ - prefixes: [DETOUR_LINKING_PREFIX], - config: linkingConfig, - async getInitialURL() { - const url = await Detour.getInitialURL(); - if (!url) { - return undefined; - } - - if (!canHandleRef.current) { - queuedUrlRef.current = url; - return undefined; - } - - return url; - }, - subscribe(listener) { - listenerRef.current = listener; - - if (canHandleRef.current && queuedUrlRef.current) { - const queuedUrl = queuedUrlRef.current; - queuedUrlRef.current = undefined; - listener(queuedUrl); - } - - const subscription = Detour.addEventListener("url", ({ url }) => { - emitOrQueue(url); - }); - - return () => { - if (listenerRef.current === listener) { - listenerRef.current = undefined; - } - subscription.remove(); - }; - }, - }), - [], - ); - - return { linking }; -}; diff --git a/examples/react-navigation/README.md b/examples/react-navigation/README.md index 1a73281..caf078e 100644 --- a/examples/react-navigation/README.md +++ b/examples/react-navigation/README.md @@ -5,13 +5,14 @@ This example demonstrates the minimal integration of `@swmansion/react-native-de ## Scenario represented - Navigation stack: `Home` and `Details`. -- App uses React Navigation `linking` (`getInitialURL` + `subscribe`) to receive URLs from Detour. +- App uses `useDetourReactNavigationLinking` to wire React Navigation `linking` (`getInitialURL` + `subscribe`) to Detour. ## Deep link handling model - Detour exposes URL APIs for React Navigation integration: - `Detour.getInitialURL()` - `Detour.addEventListener("url", ({ url }) => ...)` +- The helper hook in this example wraps the API and returns a ready-to-use `linking` object. - Screen mapping is handled by the React Navigation linking config (`Details: "details"`, `NotFound: "*"`) instead of imperative `navigate(...)` mapping. - Reference docs: https://reactnavigation.org/docs/deep-linking?config=static#integrating-with-other-tools diff --git a/examples/react-navigation/src/App.tsx b/examples/react-navigation/src/App.tsx index 4a3c942..ac02b3d 100644 --- a/examples/react-navigation/src/App.tsx +++ b/examples/react-navigation/src/App.tsx @@ -4,16 +4,15 @@ import { Text, View } from "react-native"; import * as SplashScreen from "expo-splash-screen"; -import { type LinkingOptions, NavigationContainer } from "@react-navigation/native"; +import { NavigationContainer } from "@react-navigation/native"; import { type Config, - DETOUR_LINKING_PREFIX, - Detour, DetourProvider, + useDetourReactNavigationLinking, } from "@swmansion/react-native-detour"; -import { Navigation, type RootStackParamList, linkingConfig } from "./navigation"; +import { Navigation, linkingConfig } from "./navigation"; import { styles } from "./styles"; const hasCredentials = @@ -49,24 +48,9 @@ const detourConfig: Config = { SplashScreen.preventAutoHideAsync(); -const linking: LinkingOptions = { - 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(); - }; - }, -}; - const AppNavigator = () => { + const linking = useDetourReactNavigationLinking({ config: linkingConfig }); + return ( void; }; + +export type DetourReactNavigationLinking = { + prefixes: string[]; + config: Config; + getInitialURL: () => Promise; + subscribe: (listener: (url: string) => void) => () => void; +}; + +export type UseDetourReactNavigationLinkingOptions = { + config: Config; + canHandleUrl?: boolean; + prefixes?: string[]; +}; diff --git a/packages/react-native-detour/src/reactNavigation.ts b/packages/react-native-detour/src/reactNavigation.ts index 4c844a0..d9ac200 100644 --- a/packages/react-native-detour/src/reactNavigation.ts +++ b/packages/react-native-detour/src/reactNavigation.ts @@ -1,11 +1,23 @@ -import type { DetourUrlEvent, DetourUrlSubscription } from "./links/types/index"; +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import type { + DetourReactNavigationLinking, + DetourUrlEvent, + DetourUrlSubscription, + UseDetourReactNavigationLinkingOptions, +} from "./links/types/index"; import { DETOUR_LINKING_PREFIX, addReactNavigationEventListener, getReactNavigationInitialUrl, } from "./links/utils/reactNavigation"; -export type { DetourUrlEvent, DetourUrlSubscription }; +export type { + DetourReactNavigationLinking, + DetourUrlEvent, + DetourUrlSubscription, + UseDetourReactNavigationLinkingOptions, +}; type DetourReactNavigationApi = { getInitialURL: () => Promise; @@ -20,4 +32,76 @@ export const Detour: DetourReactNavigationApi = { addEventListener: addReactNavigationEventListener, }; +export const useDetourReactNavigationLinking = ({ + config, + canHandleUrl = true, + prefixes = [DETOUR_LINKING_PREFIX], +}: UseDetourReactNavigationLinkingOptions): DetourReactNavigationLinking => { + const listenerRef = useRef<((url: string) => void) | undefined>(undefined); + const queuedUrlRef = useRef(undefined); + const canHandleRef = useRef(canHandleUrl); + canHandleRef.current = canHandleUrl; + + const emitOrQueue = useCallback((url: string) => { + if (canHandleRef.current && listenerRef.current) { + listenerRef.current(url); + return; + } + + // Keep only the latest pending link while the app gate is closed. + queuedUrlRef.current = url; + }, []); + + useEffect(() => { + if (!canHandleUrl || !listenerRef.current || !queuedUrlRef.current) { + return; + } + + const queuedUrl = queuedUrlRef.current; + queuedUrlRef.current = undefined; + listenerRef.current(queuedUrl); + }, [canHandleUrl]); + + return useMemo>( + () => ({ + prefixes, + config, + async getInitialURL() { + const url = await getReactNavigationInitialUrl(); + if (!url) { + return undefined; + } + + if (!canHandleRef.current) { + queuedUrlRef.current = url; + return undefined; + } + + return url; + }, + subscribe(listener) { + listenerRef.current = listener; + + if (canHandleRef.current && queuedUrlRef.current) { + const queuedUrl = queuedUrlRef.current; + queuedUrlRef.current = undefined; + listener(queuedUrl); + } + + const subscription = addReactNavigationEventListener("url", ({ url }) => { + emitOrQueue(url); + }); + + return () => { + if (listenerRef.current === listener) { + listenerRef.current = undefined; + } + subscription.remove(); + }; + }, + }), + [config, emitOrQueue, prefixes], + ); +}; + export { DETOUR_LINKING_PREFIX }; From a24bafd2f049b1aa0053f716fec9ce5a2b42bd3a Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Wed, 13 May 2026 13:18:47 +0200 Subject: [PATCH 4/9] feat: update metro.config.js for monorepo support --- examples/react-navigation-advanced/metro.config.js | 13 +++++++++++++ examples/react-navigation/metro.config.js | 13 +++++++++++++ 2 files changed, 26 insertions(+) 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/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; From d6719b6587b459e6e5e656f83e0ae0957ccd6a00 Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Wed, 13 May 2026 16:43:36 +0200 Subject: [PATCH 5/9] refactor: update react-navigation example with custom linking --- README.md | 6 ++--- examples/react-navigation-advanced/app.json | 12 ++++----- examples/react-navigation/README.md | 15 ++++++------ examples/react-navigation/src/App.tsx | 27 +++++++++++++++++---- 4 files changed, 39 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b24443b..ab55b80 100644 --- a/README.md +++ b/README.md @@ -177,10 +177,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 flow and dedicated helper API | The monorepo uses **pnpm workspaces**. Start by installing all dependencies from the repo root: 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/README.md b/examples/react-navigation/README.md index caf078e..d80cb0e 100644 --- a/examples/react-navigation/README.md +++ b/examples/react-navigation/README.md @@ -5,16 +5,17 @@ This example demonstrates the minimal integration of `@swmansion/react-native-de ## Scenario represented - Navigation stack: `Home` and `Details`. -- App uses `useDetourReactNavigationLinking` to wire React Navigation `linking` (`getInitialURL` + `subscribe`) to Detour. +- App is configured with React Navigation linking and Detour as the deep-link source. ## Deep link handling model -- Detour exposes URL APIs for React Navigation integration: - - `Detour.getInitialURL()` - - `Detour.addEventListener("url", ({ url }) => ...)` -- The helper hook in this example wraps the API and returns a ready-to-use `linking` object. -- Screen mapping is handled by the React Navigation linking config (`Details: "details"`, `NotFound: "*"`) instead of imperative `navigate(...)` mapping. -- Reference docs: https://reactnavigation.org/docs/deep-linking?config=static#integrating-with-other-tools +- 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 the dedicated helper API (`useDetourReactNavigationLinking`), see `examples/react-navigation-advanced`. ## Test flow diff --git a/examples/react-navigation/src/App.tsx b/examples/react-navigation/src/App.tsx index ac02b3d..1fa25f0 100644 --- a/examples/react-navigation/src/App.tsx +++ b/examples/react-navigation/src/App.tsx @@ -1,18 +1,19 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { Text, View } from "react-native"; import * as SplashScreen from "expo-splash-screen"; -import { NavigationContainer } from "@react-navigation/native"; +import { type LinkingOptions, NavigationContainer } from "@react-navigation/native"; import { type Config, + DETOUR_LINKING_PREFIX, + Detour, DetourProvider, - useDetourReactNavigationLinking, } from "@swmansion/react-native-detour"; -import { Navigation, linkingConfig } from "./navigation"; +import { Navigation, type RootStackParamList, linkingConfig } from "./navigation"; import { styles } from "./styles"; const hasCredentials = @@ -49,7 +50,23 @@ const detourConfig: Config = { SplashScreen.preventAutoHideAsync(); const AppNavigator = () => { - const linking = useDetourReactNavigationLinking({ config: linkingConfig }); + 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 ( Date: Wed, 13 May 2026 17:35:28 +0200 Subject: [PATCH 6/9] refactor: rename AuthScreens to renderAuthScreens --- examples/react-navigation-advanced/src/navigation/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/react-navigation-advanced/src/navigation/index.tsx b/examples/react-navigation-advanced/src/navigation/index.tsx index 9716416..1bd921b 100644 --- a/examples/react-navigation-advanced/src/navigation/index.tsx +++ b/examples/react-navigation-advanced/src/navigation/index.tsx @@ -46,7 +46,7 @@ const screenOptions = { contentStyle: { backgroundColor: colors.background }, }; -function AuthScreens({ +function renderAuthScreens({ isSignedIn, isOnboardingCompleted, }: { @@ -77,7 +77,7 @@ export function Navigation() { return ( - + {renderAuthScreens({ isSignedIn, isOnboardingCompleted })} ); From 22dd4ce257d6168f03527777a80b841d7c8d1981 Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Thu, 14 May 2026 12:36:09 +0200 Subject: [PATCH 7/9] feat: simplify auth-gated deep linking with React Navigation --- README.md | 38 ++++---- examples/react-navigation-advanced/README.md | 14 +-- .../react-navigation-advanced/src/App.tsx | 35 +++++--- .../src/navigation/index.tsx | 9 +- .../src/navigation/screens/Explore.tsx | 4 +- .../src/navigation/screens/Home.tsx | 4 +- .../src/navigation/screens/Onboarding.tsx | 5 +- .../src/navigation/screens/Settings.tsx | 4 +- .../src/useDetourGate.ts | 20 ----- examples/react-navigation/README.md | 3 +- packages/react-native-detour/src/index.ts | 4 +- .../src/links/types/index.ts | 13 --- .../src/reactNavigation.ts | 88 +------------------ 13 files changed, 71 insertions(+), 170 deletions(-) delete mode 100644 examples/react-navigation-advanced/src/useDetourGate.ts diff --git a/README.md b/README.md index ab55b80..3b395b1 100644 --- a/README.md +++ b/README.md @@ -136,17 +136,28 @@ 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, use the helper hook to avoid custom queueing boilerplate: - -```ts -import { useDetourReactNavigationLinking } from "@swmansion/react-native-detour"; - -const linking = useDetourReactNavigationLinking({ - config: linkingConfig, - canHandleUrl: isSignedIn && isOnboardingCompleted, -}); +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: @@ -180,7 +191,7 @@ All example apps with Detour SDK integrated live in `examples/`: | `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` | Minimal React Navigation example | -| `examples/react-navigation-advanced` | React Navigation with auth flow and dedicated helper API | +| `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: @@ -328,18 +339,11 @@ export type DetourUrlEvent = { export type DetourUrlSubscription = { remove: () => void; }; - -export type UseDetourReactNavigationLinkingOptions = { - config: Config; - canHandleUrl?: boolean; - prefixes?: string[]; -}; ``` ```js Detour.getInitialURL(): Promise Detour.addEventListener("url", (event: DetourUrlEvent) => void): DetourUrlSubscription -useDetourReactNavigationLinking(options): linking ``` --- diff --git a/examples/react-navigation-advanced/README.md b/examples/react-navigation-advanced/README.md index 201c81f..fa1bfbd 100644 --- a/examples/react-navigation-advanced/README.md +++ b/examples/react-navigation-advanced/README.md @@ -6,21 +6,21 @@ 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` exposes auth/onboarding gate state and `useDetourReactNavigationLinking` handles queued URL delivery. -- React Navigation linking uses the SDK adapter API: +- React Navigation linking uses the SDK adapter API as the URL source: - `Detour.getInitialURL()` - `Detour.addEventListener("url", ({ url }) => ...)` -- The helper hook (`useDetourReactNavigationLinking`) wraps these APIs and removes custom bridge boilerplate. +- 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 queued. -- 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 and the queued URL is delivered to React Navigation linking, which resolves `details` (or falls through to `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`). Reference docs: -https://reactnavigation.org/docs/deep-linking?config=static#integrating-with-other-tools +- 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/src/App.tsx b/examples/react-navigation-advanced/src/App.tsx index fcc54ae..31ab1d0 100644 --- a/examples/react-navigation-advanced/src/App.tsx +++ b/examples/react-navigation-advanced/src/App.tsx @@ -1,22 +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 } from "@react-navigation/native"; +import { type LinkingOptions, NavigationContainer } from "@react-navigation/native"; import { type Config, + DETOUR_LINKING_PREFIX, + Detour, DetourProvider, - useDetourReactNavigationLinking, } from "@swmansion/react-native-detour"; import { AuthProvider } from "./auth"; -import { Navigation, linkingConfig } 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; @@ -53,17 +53,28 @@ SplashScreen.preventAutoHideAsync(); SystemUI.setBackgroundColorAsync(colors.background); const AppRoot = () => { - const [isNavigationReady, setNavigationReady] = useState(false); - const { canHandleDetourLink } = useDetourGate(isNavigationReady); - const linking = useDetourReactNavigationLinking({ - config: linkingConfig, - canHandleUrl: canHandleDetourLink, - }); + 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)} + onReady={() => SplashScreen.hideAsync()} theme={{ dark: true, colors: { diff --git a/examples/react-navigation-advanced/src/navigation/index.tsx b/examples/react-navigation-advanced/src/navigation/index.tsx index 1bd921b..4ef6ce3 100644 --- a/examples/react-navigation-advanced/src/navigation/index.tsx +++ b/examples/react-navigation-advanced/src/navigation/index.tsx @@ -68,7 +68,9 @@ function renderAuthScreens({ } // 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(); @@ -76,7 +78,10 @@ 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 3b0b34a..0000000 --- a/examples/react-navigation-advanced/src/useDetourGate.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useEffect } from "react"; - -import * as SplashScreen from "expo-splash-screen"; - -import { useAuth } from "./auth"; - -// Coordinates auth/onboarding gating with the React Navigation linking bridge. -// It keeps splash handling in one place and returns when deep-link navigation -// is allowed to proceed. -export const useDetourGate = (isNavigationReady: boolean) => { - const { isLoaded, isSignedIn, isOnboardingCompleted } = useAuth(); - const canHandleDetourLink = isLoaded && isSignedIn && isOnboardingCompleted; - - useEffect(() => { - if (!isNavigationReady || !isLoaded) return; - SplashScreen.hideAsync(); - }, [isNavigationReady, isLoaded]); - - return { canHandleDetourLink }; -}; diff --git a/examples/react-navigation/README.md b/examples/react-navigation/README.md index d80cb0e..af4f4bf 100644 --- a/examples/react-navigation/README.md +++ b/examples/react-navigation/README.md @@ -15,7 +15,8 @@ This example demonstrates the minimal integration of `@swmansion/react-native-de - For React Navigation linking details, see: https://reactnavigation.org/docs/deep-linking?config=static#integrating-with-other-tools -For the dedicated helper API (`useDetourReactNavigationLinking`), see `examples/react-navigation-advanced`. +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/packages/react-native-detour/src/index.ts b/packages/react-native-detour/src/index.ts index dea7b6f..1ad6571 100644 --- a/packages/react-native-detour/src/index.ts +++ b/packages/react-native-detour/src/index.ts @@ -1,16 +1,14 @@ export { DetourProvider, useDetourContext } from "./DetourContext"; -export { DETOUR_LINKING_PREFIX, Detour, useDetourReactNavigationLinking } from "./reactNavigation"; +export { DETOUR_LINKING_PREFIX, Detour } from "./reactNavigation"; export type { Config, - DetourReactNavigationLinking, DetourContextType, DetourLink, DetourUrlEvent, DetourUrlSubscription, DetourStorage, LinkType, - UseDetourReactNavigationLinkingOptions, } from "./links/types/index"; export { DetourAnalytics } from "./analytics/analytics"; diff --git a/packages/react-native-detour/src/links/types/index.ts b/packages/react-native-detour/src/links/types/index.ts index 6ae2f6b..f254ad0 100644 --- a/packages/react-native-detour/src/links/types/index.ts +++ b/packages/react-native-detour/src/links/types/index.ts @@ -53,16 +53,3 @@ export type DetourUrlEvent = { export type DetourUrlSubscription = { remove: () => void; }; - -export type DetourReactNavigationLinking = { - prefixes: string[]; - config: Config; - getInitialURL: () => Promise; - subscribe: (listener: (url: string) => void) => () => void; -}; - -export type UseDetourReactNavigationLinkingOptions = { - config: Config; - canHandleUrl?: boolean; - prefixes?: string[]; -}; diff --git a/packages/react-native-detour/src/reactNavigation.ts b/packages/react-native-detour/src/reactNavigation.ts index d9ac200..4c844a0 100644 --- a/packages/react-native-detour/src/reactNavigation.ts +++ b/packages/react-native-detour/src/reactNavigation.ts @@ -1,23 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; - -import type { - DetourReactNavigationLinking, - DetourUrlEvent, - DetourUrlSubscription, - UseDetourReactNavigationLinkingOptions, -} from "./links/types/index"; +import type { DetourUrlEvent, DetourUrlSubscription } from "./links/types/index"; import { DETOUR_LINKING_PREFIX, addReactNavigationEventListener, getReactNavigationInitialUrl, } from "./links/utils/reactNavigation"; -export type { - DetourReactNavigationLinking, - DetourUrlEvent, - DetourUrlSubscription, - UseDetourReactNavigationLinkingOptions, -}; +export type { DetourUrlEvent, DetourUrlSubscription }; type DetourReactNavigationApi = { getInitialURL: () => Promise; @@ -32,76 +20,4 @@ export const Detour: DetourReactNavigationApi = { addEventListener: addReactNavigationEventListener, }; -export const useDetourReactNavigationLinking = ({ - config, - canHandleUrl = true, - prefixes = [DETOUR_LINKING_PREFIX], -}: UseDetourReactNavigationLinkingOptions): DetourReactNavigationLinking => { - const listenerRef = useRef<((url: string) => void) | undefined>(undefined); - const queuedUrlRef = useRef(undefined); - const canHandleRef = useRef(canHandleUrl); - canHandleRef.current = canHandleUrl; - - const emitOrQueue = useCallback((url: string) => { - if (canHandleRef.current && listenerRef.current) { - listenerRef.current(url); - return; - } - - // Keep only the latest pending link while the app gate is closed. - queuedUrlRef.current = url; - }, []); - - useEffect(() => { - if (!canHandleUrl || !listenerRef.current || !queuedUrlRef.current) { - return; - } - - const queuedUrl = queuedUrlRef.current; - queuedUrlRef.current = undefined; - listenerRef.current(queuedUrl); - }, [canHandleUrl]); - - return useMemo>( - () => ({ - prefixes, - config, - async getInitialURL() { - const url = await getReactNavigationInitialUrl(); - if (!url) { - return undefined; - } - - if (!canHandleRef.current) { - queuedUrlRef.current = url; - return undefined; - } - - return url; - }, - subscribe(listener) { - listenerRef.current = listener; - - if (canHandleRef.current && queuedUrlRef.current) { - const queuedUrl = queuedUrlRef.current; - queuedUrlRef.current = undefined; - listener(queuedUrl); - } - - const subscription = addReactNavigationEventListener("url", ({ url }) => { - emitOrQueue(url); - }); - - return () => { - if (listenerRef.current === listener) { - listenerRef.current = undefined; - } - subscription.remove(); - }; - }, - }), - [config, emitOrQueue, prefixes], - ); -}; - export { DETOUR_LINKING_PREFIX }; From 2902f7c02028971e80c9d1d341449857d7470c8e Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Thu, 14 May 2026 13:10:08 +0200 Subject: [PATCH 8/9] docs: add dev-only warning explanation to README --- examples/react-navigation-advanced/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/react-navigation-advanced/README.md b/examples/react-navigation-advanced/README.md index fa1bfbd..9492582 100644 --- a/examples/react-navigation-advanced/README.md +++ b/examples/react-navigation-advanced/README.md @@ -18,6 +18,12 @@ This example demonstrates an auth-gated React Navigation app with Detour integra - 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`). +### 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`) From 25e9c7d47f8fce6d3923cf76d333213710a6ecbc Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Thu, 14 May 2026 16:41:43 +0200 Subject: [PATCH 9/9] docs: add note --- examples/react-navigation-advanced/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/react-navigation-advanced/README.md b/examples/react-navigation-advanced/README.md index 9492582..17933db 100644 --- a/examples/react-navigation-advanced/README.md +++ b/examples/react-navigation-advanced/README.md @@ -18,6 +18,8 @@ This example demonstrates an auth-gated React Navigation app with Detour integra - 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.