From 0481652161174c81af96ccb986b812506c13949e Mon Sep 17 00:00:00 2001 From: yasha-meursault Date: Mon, 23 Mar 2026 21:41:44 +0400 Subject: [PATCH 1/3] SWR's keepPreviousData retains stale quotes across key changes (e.g. different token pairs), which can flash incorrect pricing. Using a ref ensures stale data is only shown while the same key is revalidating and is cleared on errors. --- apps/app/hooks/useFee.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/app/hooks/useFee.ts b/apps/app/hooks/useFee.ts index e76c2554..840bc13e 100644 --- a/apps/app/hooks/useFee.ts +++ b/apps/app/hooks/useFee.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import useSWR from 'swr' import { parseUnits } from 'viem' import { SwapFormValues } from '../components/DTOs/SwapFormValues' @@ -100,6 +100,7 @@ export function useQuoteData(formValues: Props | undefined, refreshInterval?: nu const [debouncedAmount, setDebouncedAmount] = useState(convertedAmount) const [isDebouncing, setIsDebouncing] = useState(false) + const cachedQuote = useRef(null) useEffect(() => { if (convertedAmount === debouncedAmount) return @@ -160,19 +161,24 @@ export function useQuoteData(formValues: Props | undefined, refreshInterval?: nu } }, []) - const { data, mutate: mutateFee, error: quoteError } = useSWR( + const { data: rawData, mutate: mutateFee, error: quoteError } = useSWR( quoteURL, quoteFetchWrapper, { refreshInterval: (refreshInterval !== undefined && refreshInterval !== null) ? refreshInterval : 42000, dedupingInterval: 5000, - keepPreviousData: true, } ) + if (rawData) cachedQuote.current = rawData + if (quoteError) cachedQuote.current = null + + const data = rawData ?? cachedQuote.current + const resolvedQuote = (quoteError || !canGetQuote) ? undefined : data?.quote + const resolvedSolverId = (quoteError || !canGetQuote) ? undefined : data?.solverId return { - quote: (quoteError || !canGetQuote) ? undefined : data?.quote, - solverId: (quoteError || !canGetQuote) ? undefined : data?.solverId, + quote: resolvedQuote, + solverId: resolvedSolverId, isQuoteLoading, isDebouncing, quoteError: quoteError as QuoteError | undefined, From c8dbc30e3b2d109de1e7d387e5b506f43ea90864 Mon Sep 17 00:00:00 2001 From: yasha-meursault Date: Tue, 24 Mar 2026 22:03:14 +0400 Subject: [PATCH 2/3] fix --- apps/app/hooks/useFee.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/app/hooks/useFee.ts b/apps/app/hooks/useFee.ts index 840bc13e..5d1fa8ff 100644 --- a/apps/app/hooks/useFee.ts +++ b/apps/app/hooks/useFee.ts @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import useSWR from 'swr' +import { useCallback, useEffect, useMemo, useState } from 'react' +import useSWR, { useSWRConfig } from 'swr' import { parseUnits } from 'viem' import { SwapFormValues } from '../components/DTOs/SwapFormValues' import TrainApiClient, { SwapQuote, AggregatedQuoteResponse } from '../lib/trainApiClient' @@ -100,7 +100,6 @@ export function useQuoteData(formValues: Props | undefined, refreshInterval?: nu const [debouncedAmount, setDebouncedAmount] = useState(convertedAmount) const [isDebouncing, setIsDebouncing] = useState(false) - const cachedQuote = useRef(null) useEffect(() => { if (convertedAmount === debouncedAmount) return @@ -129,6 +128,7 @@ export function useQuoteData(formValues: Props | undefined, refreshInterval?: nu : null const isQuoteLoading = useLoadingStore((state) => state.isLoading) + const { mutate: globalMutate } = useSWRConfig() const quoteFetchWrapper = useCallback(async (url: string): Promise => { const { setLoading, key, setKey } = useLoadingStore.getState() @@ -161,19 +161,19 @@ export function useQuoteData(formValues: Props | undefined, refreshInterval?: nu } }, []) - const { data: rawData, mutate: mutateFee, error: quoteError } = useSWR( + const { data, mutate: mutateFee, error: quoteError } = useSWR( quoteURL, quoteFetchWrapper, { refreshInterval: (refreshInterval !== undefined && refreshInterval !== null) ? refreshInterval : 42000, dedupingInterval: 5000, + keepPreviousData: true, + onError: (_err, key) => { + globalMutate(key, null, { revalidate: false }) + }, } ) - if (rawData) cachedQuote.current = rawData - if (quoteError) cachedQuote.current = null - - const data = rawData ?? cachedQuote.current const resolvedQuote = (quoteError || !canGetQuote) ? undefined : data?.quote const resolvedSolverId = (quoteError || !canGetQuote) ? undefined : data?.solverId return { From 2bf532c36c13554d4acf37cc3b6ad872049a5bdc Mon Sep 17 00:00:00 2001 From: yasha-meursault Date: Wed, 25 Mar 2026 16:15:34 +0400 Subject: [PATCH 3/3] add animations of the amount and quote details on quote loading block the buttons while the quote loads fix issue with small amoutns failing validation due to JS scientific notation conversion. fix by regex testing the raw input string instead. --- apps/app/components/Swap/Atomic/Form.tsx | 4 ++-- apps/app/lib/mainStepValidator.ts | 2 +- apps/app/styles/globals.css | 26 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/app/components/Swap/Atomic/Form.tsx b/apps/app/components/Swap/Atomic/Form.tsx index 49e1cf85..51efb8b3 100644 --- a/apps/app/components/Swap/Atomic/Form.tsx +++ b/apps/app/components/Swap/Atomic/Form.tsx @@ -62,9 +62,9 @@ const SwapForm: FC = ({ polling = true, onQuoteChange }) => { shouldConnectWallet={shouldConnectWallet} shouldConnectDestinationWallet={shouldConnectDestinationWallet} values={values} - isValid={isValid && quote !== undefined} + isValid={isValid && quote !== undefined && !isQuoteLoading} errors={errors} - isSubmitting={isSubmitting || isQuoteLoading} + isSubmitting={isSubmitting} actionDisplayName={actionDisplayName} /> diff --git a/apps/app/lib/mainStepValidator.ts b/apps/app/lib/mainStepValidator.ts index ea2e76a2..f1cfb4c1 100644 --- a/apps/app/lib/mainStepValidator.ts +++ b/apps/app/lib/mainStepValidator.ts @@ -22,7 +22,7 @@ export default function MainStepValidation(): ((values: SwapFormValues) => Formi if (!amount) { errors.amount = 'Enter an amount'; } - if (amount && !/^[0-9]*[.,]?[0-9]*$/i.test(amount.toString())) { + if (values.amount && !/^[0-9]*[.,]?[0-9]*$/i.test(values.amount)) { errors.amount = 'Invalid amount'; } if (amount && amount < 0) { diff --git a/apps/app/styles/globals.css b/apps/app/styles/globals.css index ab58f1aa..328ebf6b 100644 --- a/apps/app/styles/globals.css +++ b/apps/app/styles/globals.css @@ -191,6 +191,32 @@ .rdxCommandList { max-height: var(--radix-popper-available-height); } + + @keyframes pulse-strong { + 50% { + opacity: 0.4; + } + } + .animate-pulse-strong { + animation: pulse-strong 1.4s cubic-bezier(0.77, 0, 0.17, 1) infinite; + } + + @keyframes pulse-stronger { + 50% { + opacity: 0.15; + } + } + .animate-pulse-stronger { + animation: pulse-stronger 1.4s cubic-bezier(0.77, 0, 0.17, 1) infinite; + } +} + +number-flow-react::part(left), +number-flow-react::part(right), +number-flow-react::part(left)::after, +number-flow-react::part(right)::after, +number-flow-react::part(symbol) { + padding: calc(var(--number-flow-mask-height, 0.25em) / 2) 0; } @layer base {