Skip to content

Commit de69968

Browse files
fix(mobile): reset navigation for token deeplinks
Reset nested asset-detail navigation state for token deeplinks. Redirect unresolved token assets to Market while preserving loading and error states. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 740f9fb commit de69968

9 files changed

Lines changed: 315 additions & 18 deletions

File tree

.changeset/calm-tokens-reset.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"live-mobile": minor
3+
---
4+
5+
Reset mobile navigation state for sequential token deeplinks and redirect invalid asset deeplinks to Market

apps/ledger-live-mobile/src/mvvm/features/AssetDetail/__integrations__/assetDetail.integration.test.tsx

Lines changed: 145 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import React from "react";
2+
import { View } from "react-native";
3+
import type { CurrencyResult } from "@ledgerhq/cryptoassets/hooks";
24
import { createNativeStackNavigator } from "@react-navigation/native-stack";
35
import { render, screen, waitFor, within, withFlagOverrides } from "@tests/test-renderer";
46
import { genAccount } from "@ledgerhq/ledger-wallet-framework/mocks/account";
5-
import { getCryptoCurrencyById } from "@ledgerhq/live-common/currencies/index";
7+
import {
8+
findCryptoCurrencyById,
9+
getCryptoCurrencyById,
10+
} from "@ledgerhq/live-common/currencies/index";
11+
import { createFixtureTokenAccount } from "@ledgerhq/live-common/mock/fixtures/cryptoCurrencies";
612
import type { Account } from "@ledgerhq/types-live";
7-
import { NavigatorName, ScreenName } from "~/const";
13+
import type { BaseNavigatorStackParamList } from "~/components/RootNavigator/types/BaseNavigator";
14+
import { BASE_NAVIGATOR_ID, NavigatorName, ScreenName } from "~/const";
815
import type { State } from "~/reducers/types";
916
import AssetDetailNavigator from "../Navigator";
1017
import { ASSET_DETAIL_TEST_IDS } from "../testIds";
@@ -17,6 +24,7 @@ import {
1724

1825
const mockIsCurrencyAvailable = jest.fn().mockReturnValue(false);
1926
const mockIsAcceptedCurrency = jest.fn().mockReturnValue(false);
27+
const mockUseCurrencyById = jest.fn<CurrencyResult, [string]>();
2028

2129
jest.mock("@ledgerhq/live-common/platform/providers/RampCatalogProvider/useRampCatalog", () => ({
2230
useRampCatalog: () => ({ isCurrencyAvailable: mockIsCurrencyAvailable }),
@@ -26,6 +34,15 @@ jest.mock("@ledgerhq/live-common/modularDrawer/hooks/useAcceptedCurrency", () =>
2634
useAcceptedCurrency: () => mockIsAcceptedCurrency,
2735
}));
2836

37+
jest.mock("@ledgerhq/cryptoassets/hooks", () => ({
38+
useCurrencyById: (id: string) => mockUseCurrencyById(id),
39+
}));
40+
41+
jest.mock("@features/platform-currencies", () => ({
42+
...jest.requireActual("@features/platform-currencies"),
43+
useCurrencyById: (id: string) => mockUseCurrencyById(id),
44+
}));
45+
2946
jest.mock("@ledgerhq/asset-detail", () => ({
3047
...jest.requireActual("@ledgerhq/asset-detail"),
3148
useTradeAvailability: jest.fn(),
@@ -54,7 +71,12 @@ jest.mock("@ledgerhq/live-common/bridge/useAccountBridge", () => ({
5471
),
5572
}));
5673

57-
const Stack = createNativeStackNavigator();
74+
const Stack = createNativeStackNavigator<BaseNavigatorStackParamList, typeof BASE_NAVIGATOR_ID>();
75+
const MARKET_LIST_TEST_ID = "market-list-screen";
76+
77+
function MarketListScreen() {
78+
return <View testID={MARKET_LIST_TEST_ID} />;
79+
}
5880

5981
type NavigatorParams = {
6082
currencyId: string;
@@ -68,7 +90,7 @@ function AssetDetailTestNavigator({
6890
params?: NavigatorParams;
6991
} = {}) {
7092
return (
71-
<Stack.Navigator>
93+
<Stack.Navigator id={BASE_NAVIGATOR_ID}>
7294
<Stack.Screen
7395
name={NavigatorName.AssetDetail}
7496
component={AssetDetailNavigator}
@@ -78,6 +100,7 @@ function AssetDetailTestNavigator({
78100
}}
79101
options={{ headerShown: false }}
80102
/>
103+
<Stack.Screen name={ScreenName.MarketList} component={MarketListScreen} />
81104
</Stack.Navigator>
82105
);
83106
}
@@ -125,6 +148,12 @@ function withBlacklistedTokens(tokenIds: string[]) {
125148

126149
describe("AssetDetail screen layout", () => {
127150
beforeEach(() => {
151+
mockUseCurrencyById.mockReset();
152+
mockUseCurrencyById.mockImplementation((id: string) => ({
153+
currency: findCryptoCurrencyById(id),
154+
loading: false,
155+
error: undefined,
156+
}));
128157
mockIsCurrencyAvailable.mockReturnValue(false);
129158
mockIsAcceptedCurrency.mockReturnValue(false);
130159
setAvailability();
@@ -487,5 +516,117 @@ describe("AssetDetail screen layout", () => {
487516
});
488517
expect(screen.getByTestId(ASSET_DETAIL_TEST_IDS.balanceGraph)).toBeVisible();
489518
});
519+
520+
it.each(["deeplink_asset", "deeplink_market"])(
521+
"redirects a missing token %s deeplink to Market",
522+
async source => {
523+
const tokenId = "ethereum/erc20/does_not_exist";
524+
mockUseCurrencyById.mockReturnValue({
525+
currency: undefined,
526+
loading: false,
527+
error: undefined,
528+
});
529+
530+
render(
531+
<AssetDetailTestNavigator
532+
params={{
533+
currencyId: tokenId,
534+
source,
535+
marketState: { id: tokenId, ledgerIds: [tokenId] },
536+
}}
537+
/>,
538+
);
539+
540+
await waitFor(() => expect(screen.getByTestId(MARKET_LIST_TEST_ID)).toBeVisible());
541+
expect(screen.queryByTestId(ASSET_DETAIL_TEST_IDS.screen)).toBeNull();
542+
},
543+
);
544+
545+
it("keeps a valid token deeplink on Asset Detail", () => {
546+
const token = createFixtureTokenAccount().token;
547+
mockUseCurrencyById.mockReturnValue({
548+
currency: token,
549+
loading: false,
550+
error: undefined,
551+
});
552+
553+
render(
554+
<AssetDetailTestNavigator
555+
params={{
556+
currencyId: token.id,
557+
source: "deeplink_asset",
558+
marketState: { id: token.id, ledgerIds: [token.id] },
559+
}}
560+
/>,
561+
);
562+
563+
expect(screen.getByTestId(ASSET_DETAIL_TEST_IDS.screen)).toBeVisible();
564+
expect(screen.queryByTestId(MARKET_LIST_TEST_ID)).toBeNull();
565+
});
566+
567+
it("keeps Asset Detail visible while the token lookup is loading", () => {
568+
const tokenId = "ethereum/erc20/loading";
569+
mockUseCurrencyById.mockReturnValue({
570+
currency: undefined,
571+
loading: true,
572+
error: undefined,
573+
});
574+
575+
render(
576+
<AssetDetailTestNavigator
577+
params={{
578+
currencyId: tokenId,
579+
source: "deeplink_asset",
580+
marketState: { id: tokenId, ledgerIds: [tokenId] },
581+
}}
582+
/>,
583+
);
584+
585+
expect(screen.getByTestId(ASSET_DETAIL_TEST_IDS.screen)).toBeVisible();
586+
expect(screen.queryByTestId(MARKET_LIST_TEST_ID)).toBeNull();
587+
});
588+
589+
it("keeps the Asset Detail screen on token lookup errors", () => {
590+
const tokenId = "ethereum/erc20/unavailable";
591+
mockUseCurrencyById.mockReturnValue({
592+
currency: undefined,
593+
loading: false,
594+
error: new Error("CAL unavailable"),
595+
});
596+
597+
render(
598+
<AssetDetailTestNavigator
599+
params={{
600+
currencyId: tokenId,
601+
source: "deeplink_market",
602+
marketState: { id: tokenId, ledgerIds: [tokenId] },
603+
}}
604+
/>,
605+
);
606+
607+
expect(screen.getByTestId(ASSET_DETAIL_TEST_IDS.screen)).toBeVisible();
608+
expect(screen.queryByTestId(MARKET_LIST_TEST_ID)).toBeNull();
609+
});
610+
611+
it("keeps market-only assets on Asset Detail outside deeplinks", () => {
612+
mockUseCurrencyById.mockReturnValue({
613+
currency: undefined,
614+
loading: false,
615+
error: undefined,
616+
});
617+
618+
render(
619+
<AssetDetailTestNavigator
620+
params={{
621+
currencyId: "market-only-asset",
622+
source: "market_banner",
623+
marketState: { id: "market-only-asset" },
624+
}}
625+
/>,
626+
);
627+
628+
expect(screen.getByTestId(ASSET_DETAIL_TEST_IDS.screen)).toBeVisible();
629+
expect(screen.queryByTestId(MARKET_LIST_TEST_ID)).toBeNull();
630+
});
490631
});
491632
});

apps/ledger-live-mobile/src/mvvm/features/AssetDetail/screens/AssetDetail/index.tsx

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,46 @@
1-
import React, { useLayoutEffect } from "react";
1+
import React, { useEffect, useLayoutEffect } from "react";
22
import type { NativeStackHeaderRightProps } from "@react-navigation/native-stack";
33
import { useNavigation } from "@react-navigation/native";
4+
import type { CompositeNavigationProp } from "@react-navigation/native";
45
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
56
import CurrencyIcon from "~/components/CurrencyIcon";
67
import type { LumenNativeStackNavigationOptions } from "LLM/components/Navigation";
78
import type { AssetDetailNavigatorParamsList } from "LLM/features/AssetDetail/types";
8-
import { ScreenName } from "~/const";
9+
import type { BaseNavigatorStackParamList } from "~/components/RootNavigator/types/BaseNavigator";
10+
import { BASE_NAVIGATOR_ID, ScreenName } from "~/const";
911
import { ASSET_DETAIL_TEST_IDS } from "../../testIds";
1012
import { useAssetDetailViewModel } from "./useAssetDetailViewModel";
1113
import { AssetDetailView } from "./AssetDetailView";
1214
import { AssetCoinOptionsTrailing } from "./components/CoinOptions/AssetCoinOptionsTrailing";
1315

14-
type NavigationProps = NativeStackNavigationProp<
15-
AssetDetailNavigatorParamsList,
16-
ScreenName.AssetDetail
16+
type BaseNavigatorNavigation = NativeStackNavigationProp<
17+
BaseNavigatorStackParamList,
18+
keyof BaseNavigatorStackParamList,
19+
typeof BASE_NAVIGATOR_ID
20+
>;
21+
22+
type NavigationProps = CompositeNavigationProp<
23+
NativeStackNavigationProp<AssetDetailNavigatorParamsList, ScreenName.AssetDetail>,
24+
BaseNavigatorNavigation
1725
>;
1826

1927
export default function AssetDetail() {
2028
const viewModel = useAssetDetailViewModel();
21-
const { currency, coinOptions } = viewModel;
29+
const { currency, coinOptions, shouldRedirectToMarket } = viewModel;
2230
const navigation = useNavigation<NavigationProps>();
2331

32+
useEffect(() => {
33+
if (!shouldRedirectToMarket) return;
34+
35+
const baseNavigation = navigation.getParent<BaseNavigatorNavigation>(BASE_NAVIGATOR_ID);
36+
if (baseNavigation) {
37+
baseNavigation.replace(ScreenName.MarketList);
38+
return;
39+
}
40+
41+
navigation.navigate(ScreenName.MarketList);
42+
}, [navigation, shouldRedirectToMarket]);
43+
2444
useLayoutEffect(() => {
2545
if (!currency) return;
2646

@@ -57,5 +77,7 @@ export default function AssetDetail() {
5777
navigation.setOptions(opts);
5878
}, [navigation, currency, coinOptions.openCoinOptions, coinOptions.trailingAccessibilityLabel]);
5979

80+
if (shouldRedirectToMarket) return null;
81+
6082
return <AssetDetailView {...viewModel} />;
6183
}

apps/ledger-live-mobile/src/mvvm/features/AssetDetail/screens/AssetDetail/useAssetDetailViewModel.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { isRobinhoodExclusiveAsset } from "./utils/isRobinhoodExclusiveAsset";
1919

2020
type Route = StackNavigatorProps<AssetDetailNavigatorParamsList, ScreenName.AssetDetail>["route"];
2121

22+
const ASSET_DETAIL_DEEPLINK_SOURCES = new Set(["deeplink_asset", "deeplink_market"]);
23+
2224
export function useAssetDetailViewModel() {
2325
const route = useRoute<Route>();
2426
const { currencyId, source, marketState } = route.params;
@@ -35,8 +37,18 @@ export function useAssetDetailViewModel() {
3537
);
3638

3739
const ledgerIdFallback = marketState?.ledgerIds?.[0] ?? currencyId;
38-
const { currency: ledgerCurrencyById } = useCurrencyById(ledgerIdFallback);
40+
const {
41+
currency: ledgerCurrencyById,
42+
loading: isCurrencyLoading,
43+
error: currencyError,
44+
} = useCurrencyById(ledgerIdFallback);
3945
const currency = distributionItem?.currency ?? ledgerCurrencyById;
46+
const shouldRedirectToMarket =
47+
ASSET_DETAIL_DEEPLINK_SOURCES.has(source ?? "") &&
48+
!distribution.isLoading &&
49+
!isCurrencyLoading &&
50+
!currencyError &&
51+
!currency;
4052

4153
const { marketApiId, knownLedgerIds, knownMarketId } = useMemo(
4254
() =>
@@ -107,5 +119,6 @@ export function useAssetDetailViewModel() {
107119
coinOptions,
108120
isLoading,
109121
ledgerIds: receiveLedgerIds,
122+
shouldRedirectToMarket,
110123
};
111124
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { getStateFromPath } from "@react-navigation/native";
2+
import { ScreenName } from "~/const";
3+
import { handleAssetDetailDeeplink } from "~/navigation/deeplinks/handleAssetDetailDeeplink";
4+
import { getActionFromAssetDetailDeeplinkState } from "../getActionFromAssetDetailDeeplinkState";
5+
6+
function createAssetDetailState(params: Parameters<typeof handleAssetDetailDeeplink>[0]) {
7+
const state = handleAssetDetailDeeplink(params);
8+
9+
if (!state) {
10+
throw new Error("Expected an Asset Detail navigation state");
11+
}
12+
13+
return state;
14+
}
15+
16+
describe("getActionFromAssetDetailDeeplinkState", () => {
17+
it("should reset navigation for each sequential token Asset Detail deeplink", () => {
18+
const firstState = createAssetDetailState({
19+
currencyId: "ethereum/erc20/usd_tether__erc20_",
20+
source: "deeplink_asset",
21+
marketState: {
22+
id: "ethereum/erc20/usd_tether__erc20_",
23+
ledgerIds: ["ethereum/erc20/usd_tether__erc20_"],
24+
},
25+
});
26+
const secondState = createAssetDetailState({
27+
currencyId: "ethereum/erc20/usd__coin",
28+
source: "deeplink_asset",
29+
marketState: {
30+
id: "ethereum/erc20/usd__coin",
31+
ledgerIds: ["ethereum/erc20/usd__coin"],
32+
},
33+
});
34+
35+
expect(getActionFromAssetDetailDeeplinkState(firstState)).toEqual({
36+
type: "RESET",
37+
payload: firstState,
38+
});
39+
expect(getActionFromAssetDetailDeeplinkState(secondState)).toEqual({
40+
type: "RESET",
41+
payload: secondState,
42+
});
43+
});
44+
45+
it("should keep the default navigation action for coin Asset Detail deeplinks", () => {
46+
const state = createAssetDetailState({
47+
currencyId: "bitcoin",
48+
source: "deeplink_asset",
49+
});
50+
51+
expect(getActionFromAssetDetailDeeplinkState(state)?.type).toBe("NAVIGATE");
52+
});
53+
54+
it("should keep the default navigation action for Stake deeplinks", () => {
55+
const state = getStateFromPath("earn?action=stake", {
56+
screens: { [ScreenName.Earn]: "earn" },
57+
});
58+
59+
if (!state) {
60+
throw new Error("Expected a Stake navigation state");
61+
}
62+
63+
expect(getActionFromAssetDetailDeeplinkState(state)?.type).toBe("NAVIGATE");
64+
});
65+
});

0 commit comments

Comments
 (0)