Skip to content

Commit 3215c16

Browse files
committed
Fix native deeplink paths for the Submit onboarding intent
Custom-scheme links like new-expensify://onboarding?intent=submit put the route where a host would sit, so stripping the origin discarded the route and the intent was lost. Links opened while the app was already running were missed too, since the latched URL is stale by then and only secure links were recorded.
1 parent 8b21e71 commit 3215c16

4 files changed

Lines changed: 34 additions & 8 deletions

File tree

src/DeepLinkHandler.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import useOnyx from './hooks/useOnyx';
1111
import {openReportFromDeepLink} from './libs/actions/Link';
1212
import * as Report from './libs/actions/Report';
1313
import {hasAuthToken, isAnonymousUser} from './libs/actions/Session';
14+
import getOnboardingIntentFromUrl from './libs/getOnboardingIntentFromUrl';
1415
import Log from './libs/Log';
1516
import {getReportIDFromLink} from './libs/ReportUtils';
1617
import {endSpan} from './libs/telemetry/activeSpans';
@@ -141,10 +142,11 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) {
141142
Log.info('[Deep link] introSelected is undefined when processing URL change', false, {url: state.url});
142143
}
143144
const isCurrentlyAuthenticated = hasAuthToken();
144-
// A Submit-via-PDF secure access link can arrive while the app is already running (warm), where
145-
// getInitialURL() is empty. Record it so onboarding suppression has a session-sticky signal, the same
146-
// way the cold path does via onInitialUrl above. Scoped to secure links so other deep links are unaffected.
147-
if (hasSecureLinkKey(state.url)) {
145+
// A Submit-via-PDF secure access link, or an onboarding deeplink carrying an intent, can arrive while the
146+
// app is already running (warm), where getInitialURL() is empty. Record it so the handlers reading the
147+
// initial URL have a session-sticky signal, the same way the cold path does via onInitialUrl above.
148+
// Scoped to those two link shapes so other deep links are unaffected.
149+
if (hasSecureLinkKey(state.url) || getOnboardingIntentFromUrl(state.url)) {
148150
onInitialUrl(state.url as Route);
149151
}
150152
openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected, isSelfTourViewed, betas, session?.accountID ?? CONST.DEFAULT_NUMBER_ID);

src/hooks/useOnboardingDeeplinkIntent.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {useState} from 'react';
1212
*
1313
* The URL is latched at mount because the app rewrites it as soon as it navigates, which happens well before the
1414
* intent has been acted on. The initial URL is the only source on native, where the browser URL is empty and the
15-
* deeplink resolves asynchronously.
15+
* deeplink resolves asynchronously. It also covers links opened while the app is already running, which DeepLinkHandler
16+
* records there because the latched URL is stale by then.
1617
*/
1718
function useOnboardingDeeplinkIntent(): OnboardingIntent | undefined {
1819
const {initialURL} = useInitialURLState();

src/libs/getOnboardingIntentFromUrl.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,17 @@ function isOnboardingIntent(value: string | null): value is OnboardingIntent {
2222
return !!value && ONBOARDING_INTENT_VALUES.has(value);
2323
}
2424

25-
/** Strips the scheme and host so absolute URLs and in-app paths can be inspected the same way. */
25+
/**
26+
* Strips the scheme and, for web URLs, the host, so absolute URLs and in-app paths can be inspected the same way.
27+
*
28+
* Custom schemes have to keep the segment straight after `://`. In `new-expensify://onboarding?intent=submit` that
29+
* segment is the route rather than a host, so dropping it the way we drop `new.expensify.com` would discard the
30+
* route and lose the intent. The `app://-/` prefix puts a placeholder host there instead, which is dropped.
31+
*/
2632
function getPathWithQuery(url: string): string {
27-
const [withoutHash] = url.replace(/^[a-z][\w+.-]*:\/\/[^/]*/i, '').split('#', 2);
28-
return withoutHash.replace(/^\/+/, '');
33+
const [withoutHash] = url.split('#', 2);
34+
const withoutOrigin = /^https?:\/\//i.test(withoutHash) ? withoutHash.replace(/^https?:\/\/[^/?#]*/i, '') : withoutHash.replace(/^[a-z][\w+.-]*:\/\//i, '');
35+
return withoutOrigin.replace(/^(-\/)?\/*/, '');
2936
}
3037

3138
function getOnboardingIntentFromUrl(url: string | null | undefined): OnboardingIntent | undefined {

tests/unit/getOnboardingIntentFromUrlTest.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ describe('getOnboardingIntentFromUrl', () => {
77
expect(getOnboardingIntentFromUrl('https://new.expensify.com/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
88
});
99

10+
it('reads the intent from a custom scheme link, where the route sits where a host would', () => {
11+
expect(getOnboardingIntentFromUrl('new-expensify://onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
12+
});
13+
14+
it('reads the intent from a custom scheme link with a placeholder host', () => {
15+
expect(getOnboardingIntentFromUrl('app://-/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
16+
});
17+
18+
it('reads the intent from the exitTo of a custom scheme magic link', () => {
19+
expect(getOnboardingIntentFromUrl('new-expensify://v/12345/678910?exitTo=onboarding%3Fintent%3Dsubmit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
20+
});
21+
22+
it('reads the intent from a link served on a port, as the dev server does', () => {
23+
expect(getOnboardingIntentFromUrl('https://dev.new.expensify.com:8082/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
24+
});
25+
1026
it('reads the intent from an in-app path without an origin', () => {
1127
expect(getOnboardingIntentFromUrl('/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
1228
});

0 commit comments

Comments
 (0)