diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 8095205a5..c33e6ca89 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -24,6 +24,7 @@ import { OverlayPortalHost } from "./components/OverlayPortal"; import { useUniwindTheme } from "./lib/useUniwindTheme"; import { appBlurTargetRef } from "./lib/appBlurTarget"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; +import { shouldHandleAppLink } from "./lib/appLinking"; import "../global.css"; @@ -41,14 +42,7 @@ const appLinking = { // schemes `app.config.ts` registers, because iOS never delivers an // unregistered scheme to the app. prefixes: [Linking.createURL("/"), "pylon-code://", "pylon-code-dev://", "pylon-code-preview://"], - // The Expo dev client launches the app via - // ://expo-development-client/?url= — that URL addresses - // the launcher, not app navigation. Without this filter it falls through - // to the NotFound wildcard route on every dev launch. - // expo-sharing uses a private lifecycle URL only to wake the app. The - // persisted share inbox below owns navigation once the payload is durable. - filter: (url: string) => - !url.includes("expo-development-client") && !url.includes("://expo-sharing"), + filter: shouldHandleAppLink, }; const Navigation = createStaticNavigation(RootStack); diff --git a/apps/mobile/src/lib/appLinking.test.ts b/apps/mobile/src/lib/appLinking.test.ts new file mode 100644 index 000000000..ad75f3271 --- /dev/null +++ b/apps/mobile/src/lib/appLinking.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { shouldHandleAppLink } from "./appLinking"; + +describe("shouldHandleAppLink", () => { + it.each([ + "pylon-code://", + "pylon-code:///", + "pylon-code-dev://", + "pylon-code-dev:///", + "pylon-code-preview://", + "pylon-code-preview:///", + "PYLON-CODE://", + ])("keeps the current route for wake-only link %s", (url) => { + expect(shouldHandleAppLink(url)).toBe(false); + }); + + it.each([ + "pylon-code://threads/env-1/thread-1", + "pylon-code-dev://threads/env-1/thread-1", + "pylon-code-preview://settings/environments", + "pylon-code://pair?pairingUrl=https%3A%2F%2Fexample.test", + "pylon-code://?pairingUrl=https%3A%2F%2Fexample.test", + ])("preserves navigable and pairing link %s", (url) => { + expect(shouldHandleAppLink(url)).toBe(true); + }); + + it.each([ + "pylon-code-dev://expo-development-client/?url=packager", + "pylon-code://expo-sharing/anything", + ])("ignores native lifecycle link %s", (url) => { + expect(shouldHandleAppLink(url)).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/appLinking.ts b/apps/mobile/src/lib/appLinking.ts new file mode 100644 index 000000000..4d4897444 --- /dev/null +++ b/apps/mobile/src/lib/appLinking.ts @@ -0,0 +1,10 @@ +/** Ignore lifecycle and wake-only URLs before they reach React Navigation. */ +export function shouldHandleAppLink(url: string): boolean { + if (url.includes("expo-development-client") || url.includes("://expo-sharing")) { + return false; + } + + // iOS dictation keyboards can return to the app with only its scheme. Those + // URLs wake the app but have no route, so navigation must keep its stack. + return !/^pylon-code(?:-dev|-preview)?:\/*$/i.test(url); +}