Skip to content

Commit 359e186

Browse files
committed
fix: prevent infinite retry loops and modal re-showing issues
- Fix infinite retry loop in DRep ID validation by checking error state - Fix staking update modal showing multiple times by improving storage read logic - Default to not showing modal on storage read failures (safer UX) - Add robust parsing for storage values (handles JSON strings, plain strings, booleans) - Update cache synchronously before opening modal to prevent race conditions - Add error state check to prevent refetching on validation failures
1 parent 1250e35 commit 359e186

File tree

4 files changed

+129
-67
lines changed

4 files changed

+129
-67
lines changed

mobile/src/features/Portfolio/common/hooks/useNavigateTo.tsx

Lines changed: 62 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -18,73 +18,76 @@ export const useNavigateTo = () => {
1818
const {config} = useRemoteConfig()
1919
const tokenOutId = config?.swap?.initialPair?.tokenOut
2020

21-
return React.useRef({
22-
tokensList: () => navigation.navigate('portfolio-tokens-list'),
23-
tokenDetail: (params: PortfolioTokenDetailParams) =>
24-
navigation.navigate('portfolio-token-details', {id: params.id}),
25-
nftsList: () =>
26-
navigation.navigate('portfolio-nfts', {screen: 'nft-gallery'}),
27-
nftDetails: (id: Portfolio.Token.Id) =>
28-
navigation.navigate('portfolio-nfts', {
29-
screen: 'nft-details',
30-
params: {id},
31-
initial: true,
32-
}),
33-
resetTabAndSend: () => {
34-
navigation.reset({index: 0, routes: [{name: 'dashboard-portfolio'}]})
35-
navigation.navigate('history', {screen: 'send-start-tx'})
36-
},
37-
resetTabAndSwap: () => {
38-
navigation.reset({index: 0, routes: [{name: 'dashboard-portfolio'}]})
39-
navigation.navigate('history', {
40-
screen: 'swap',
41-
params: {
42-
screen: 'main',
43-
},
44-
})
45-
},
46-
resetTabAndSwapWithRemoteConfig: async () => {
47-
if (network === Chain.Network.Preprod) {
21+
return React.useMemo(
22+
() => ({
23+
tokensList: () => navigation.navigate('portfolio-tokens-list'),
24+
tokenDetail: (params: PortfolioTokenDetailParams) =>
25+
navigation.navigate('portfolio-token-details', {id: params.id}),
26+
nftsList: () =>
27+
navigation.navigate('portfolio-nfts', {screen: 'nft-gallery'}),
28+
nftDetails: (id: Portfolio.Token.Id) =>
29+
navigation.navigate('portfolio-nfts', {
30+
screen: 'nft-details',
31+
params: {id},
32+
initial: true,
33+
}),
34+
resetTabAndSend: () => {
35+
navigation.reset({index: 0, routes: [{name: 'dashboard-portfolio'}]})
36+
navigation.navigate('history', {screen: 'send-start-tx'})
37+
},
38+
resetTabAndSwap: () => {
39+
navigation.reset({index: 0, routes: [{name: 'dashboard-portfolio'}]})
4840
navigation.navigate('history', {
4941
screen: 'swap',
5042
params: {
51-
screen: 'preprod-notice',
43+
screen: 'main',
5244
},
5345
})
54-
return
55-
}
46+
},
47+
resetTabAndSwapWithRemoteConfig: async () => {
48+
if (network === Chain.Network.Preprod) {
49+
navigation.navigate('history', {
50+
screen: 'swap',
51+
params: {
52+
screen: 'preprod-notice',
53+
},
54+
})
55+
return
56+
}
5657

57-
swapForm.action({type: 'ResetForm'})
58+
swapForm.action({type: 'ResetForm'})
5859

59-
if (tokenOutId) {
60-
await setPendingSwapToken(tokenOutId)
61-
}
60+
if (tokenOutId) {
61+
await setPendingSwapToken(tokenOutId)
62+
}
6263

63-
navigation.reset({index: 0, routes: [{name: 'dashboard-portfolio'}]})
64-
navigation.navigate('history', {
65-
screen: 'swap',
66-
params: {
67-
screen: 'main',
68-
},
69-
})
70-
},
71-
swap: () =>
72-
navigation.navigate('history', {
73-
screen: 'swap',
74-
params: {
75-
screen: 'main',
76-
},
77-
}),
78-
swapPreprodNotice: () =>
79-
navigation.navigate('history', {
80-
screen: 'swap',
81-
params: {
82-
screen: 'preprod-notice',
83-
},
84-
}),
85-
buyAda: () =>
86-
navigation.navigate('history', {screen: 'exchange-create-order'}),
87-
} as const).current
64+
navigation.reset({index: 0, routes: [{name: 'dashboard-portfolio'}]})
65+
navigation.navigate('history', {
66+
screen: 'swap',
67+
params: {
68+
screen: 'main',
69+
},
70+
})
71+
},
72+
swap: () =>
73+
navigation.navigate('history', {
74+
screen: 'swap',
75+
params: {
76+
screen: 'main',
77+
},
78+
}),
79+
swapPreprodNotice: () =>
80+
navigation.navigate('history', {
81+
screen: 'swap',
82+
params: {
83+
screen: 'preprod-notice',
84+
},
85+
}),
86+
buyAda: () =>
87+
navigation.navigate('history', {screen: 'exchange-create-order'}),
88+
}),
89+
[navigation, network, tokenOutId, swapForm],
90+
)
8891
}
8992

9093
type PortfolioTokenDetailParams = PortfolioRoutes['portfolio-token-details']

mobile/src/features/Staking/Governance/useCases/EnterDrepIdModal/EnterDrepIdModal.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,11 +172,19 @@ export const EnterDrepIdModal = ({onSubmit, initialDrepId}: Props) => {
172172

173173
// If query is enabled but hasn't succeeded yet and isn't currently running, trigger it
174174
// Check isPending to see if query is waiting to start
175-
if (queryEnabled && !isSuccess && !isFetching && !isPending) {
175+
// Don't refetch if there's an error - user needs to fix input first
176+
if (
177+
queryEnabled &&
178+
!isSuccess &&
179+
!isFetching &&
180+
!isPending &&
181+
!isNonNullable(error)
182+
) {
176183
// Use a small delay to ensure React Query has processed the enabled state change
177184
const timer = setTimeout(() => {
178185
// Only refetch if still needed (query might have started automatically)
179-
if (!isSuccess && !isFetching && !isPending) {
186+
// Also check error again in case it was set during the delay
187+
if (!isSuccess && !isFetching && !isPending && !isNonNullable(error)) {
180188
refetch().catch(() => {
181189
// Silently handle refetch errors
182190
})
@@ -194,6 +202,7 @@ export const EnterDrepIdModal = ({onSubmit, initialDrepId}: Props) => {
194202
isSuccess,
195203
isFetching,
196204
isPending,
205+
error,
197206
refetch,
198207
])
199208

mobile/src/features/Staking/Staking/StakingUpdateModal/useStakingUpdateModal.tsx

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,27 +31,63 @@ export const useStakingUpdateModal = () => {
3131
const storage = useAsyncStorage()
3232
const queryClient = useQueryClient()
3333

34+
// Check cache first to avoid unnecessary storage reads
35+
const cachedValue = queryClient.getQueryData<boolean>(QUERY_KEY)
36+
3437
// Check if modal has been shown before (app-wide, not wallet-specific)
3538
// Use useQuery for proper caching and to prevent race conditions
3639
const hasBeenShownQuery = useQuery({
3740
queryKey: QUERY_KEY,
41+
// If we already have the value in cache (especially if it's true), use it as initial data
42+
// This prevents refetching when cache exists
43+
initialData: cachedValue,
3844
queryFn: async () => {
3945
try {
40-
// parseBoolean handles both cases: if it's already a boolean, return it; if it's a string, parse it
41-
const storedValue = await storage.getItem(
46+
// Read raw string from AsyncStorage to handle all possible formats
47+
// Storage might contain: JSON string "true", plain string "true", or boolean true
48+
const rawValue = await storage.getItem<string | null>(
4249
STAKING_UPDATE_MODAL_SHOWN_KEY,
50+
(value) => value, // Get raw string from AsyncStorage
4351
)
4452

45-
return parseBoolean(storedValue) ?? false
53+
// parseBoolean handles all formats:
54+
// - If already boolean: returns it directly
55+
// - If JSON string "true"/"false": parseSafe does JSON.parse, returns boolean
56+
// - If plain string "true": parseSafe tries JSON.parse, fails, returns undefined
57+
// Then we check if rawValue === "true" as fallback
58+
if (rawValue === null) {
59+
return false
60+
}
61+
62+
const parsed = parseBoolean(rawValue)
63+
if (parsed !== undefined) {
64+
return parsed
65+
}
66+
67+
// Fallback: handle plain string "true"/"false" if JSON.parse failed
68+
if (rawValue === 'true') {
69+
return true
70+
}
71+
if (rawValue === 'false') {
72+
return false
73+
}
74+
75+
// Default to true (don't show) if value exists but can't be parsed
76+
// Safer to assume modal was already shown rather than show it again
77+
return true
4678
} catch (error) {
47-
return false
79+
// On error reading storage, default to true (don't show modal)
80+
// Safer to assume modal was already shown rather than potentially show it multiple times
81+
// This should only happen if storage is corrupted or inaccessible
82+
return true
4883
}
4984
},
50-
placeholderData: false,
85+
placeholderData: true, // Default to true (don't show) while loading
5186
staleTime: Infinity, // Never refetch - once shown, always shown
5287
gcTime: Infinity, // Keep in cache forever
5388
refetchOnMount: false,
5489
refetchOnWindowFocus: false,
90+
retry: false, // Don't retry on failure - if storage read fails, assume not shown
5591
})
5692

5793
const setModalShown = useMutationWithInvalidations({
@@ -113,7 +149,12 @@ export const useStakingUpdateModal = () => {
113149
// Mark as triggered to prevent infinite loop
114150
hasTriggeredRef.current = true
115151

116-
// Mark as shown and update cache
152+
// Update cache FIRST (synchronously) to prevent re-triggering on remount
153+
// This ensures the modal won't show again even if storage write fails or component remounts
154+
queryClient.setQueryData(QUERY_KEY, true)
155+
156+
// Write to storage asynchronously (fire and forget)
157+
// If this fails, the cache update above still prevents re-showing
117158
setModalShown.mutate()
118159

119160
openModal({
@@ -135,6 +176,7 @@ export const useStakingUpdateModal = () => {
135176
strings,
136177
modalHeight,
137178
setModalShown,
179+
queryClient,
138180
])
139181

140182
return {isLoading: isLoadingStakingInfo || isLoadingConfig}

mobile/src/ui/ResultScreen/ResultScreenContext.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ const getDefaultConfig = (
5555
case 'delegate':
5656
case 'withdraw':
5757
case 'utxo-consolidation':
58+
return {
59+
defaultTitle: strings.txReview.failedTxTitle,
60+
defaultMessage: strings.txReview.failedTxText,
61+
defaultPrimaryAction: {
62+
title: strings.txReview.failedTxButton,
63+
onPress: navigation.resetToTxHistory,
64+
},
65+
}
5866
case 'airdrop':
5967
return {
6068
defaultTitle: strings.airdrop.redeemError,

0 commit comments

Comments
 (0)