From 429483403e7226730eb877093886a1747b3283e8 Mon Sep 17 00:00:00 2001 From: tenderdeve Date: Tue, 23 Jun 2026 16:07:35 +0530 Subject: [PATCH 1/3] fix(bridge): hide stale price impact while a new quote is loading When the destination token is changed in swap-and-bridge mode, the price impact briefly rendered a huge/scary value derived from the previous quote while the new bridge quote was still being fetched. `PriceImpactIndicator` already received the `loading` flag but rendered the (stale) percentage *and* the spinner side by side. Gate the percentage on `!priceImpactLoading` so only the spinner shows while a fresh quote is being computed, matching the rest of the bridge-quote loading UX. Closes #7429 --- .../src/common/pure/PriceImpactIndicator/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/common/pure/PriceImpactIndicator/index.tsx b/apps/cowswap-frontend/src/common/pure/PriceImpactIndicator/index.tsx index f0e40d719b2..9728855a51e 100644 --- a/apps/cowswap-frontend/src/common/pure/PriceImpactIndicator/index.tsx +++ b/apps/cowswap-frontend/src/common/pure/PriceImpactIndicator/index.tsx @@ -32,7 +32,7 @@ export function PriceImpactIndicator({ priceImpactParams, isBridging = false }: return ( - {priceImpact ? ( + {priceImpact && !priceImpactLoading ? ( {' '} Date: Tue, 23 Jun 2026 17:53:13 +0530 Subject: [PATCH 2/3] fix(trade): treat in-flight quote as price-impact loading state The previous fix only stopped \`PriceImpactIndicator\` from rendering a stale percentage during USD-loading. The reporter still saw a scary value on same-chain quote refreshes: when the destination token changes, the trade form keeps the previous \`outputCurrencyAmount\` until the new quote lands, and during that window \`useFiatValuePriceImpact\` happily divides the new input USD by the old output USD, producing a huge nonsense %. Pull \`isLoading\` / \`hasParamsChanged\` out of \`useTradeQuote()\` and fold them into the price-impact loading flag. While that combined flag is true, return \`{ priceImpact: undefined, isLoading: true }\` so every consumer falls back to the loading state (spinner) instead of rendering the stale value. Closes #7429 --- .../usePriceImpact/useFiatValuePriceImpact.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/cowswap-frontend/src/legacy/hooks/usePriceImpact/useFiatValuePriceImpact.ts b/apps/cowswap-frontend/src/legacy/hooks/usePriceImpact/useFiatValuePriceImpact.ts index 62fdc9197d5..ee3550161cd 100644 --- a/apps/cowswap-frontend/src/legacy/hooks/usePriceImpact/useFiatValuePriceImpact.ts +++ b/apps/cowswap-frontend/src/legacy/hooks/usePriceImpact/useFiatValuePriceImpact.ts @@ -8,6 +8,7 @@ import { Fraction, Percent } from '@cowprotocol/currency' import ms from 'ms.macro' import { useDerivedTradeState } from 'modules/trade' +import { useTradeQuote } from 'modules/tradeQuote' import { useTradeUsdAmounts } from 'modules/usdAmount' import { useSafeMemo } from 'common/hooks/useSafeMemo' @@ -31,7 +32,12 @@ export function useFiatValuePriceImpact(): { priceImpact: Percent | undefined; i outputAmount: { value: fiatValueOutput, isLoading: outputIsLoading }, } = useTradeUsdAmounts(inputCurrencyAmount, outputCurrencyAmount, inputToken, outputToken) - const isLoading = inputIsLoading || outputIsLoading + const { isLoading: isQuoteLoading, hasParamsChanged: quoteParamsChanged } = useTradeQuote() + + // Trade-quote signals indicate the current output amount is stale (token just changed + // or a fresh quote is in flight). Compute price impact only once the quote catches up, + // otherwise we'd display a huge nonsense % derived from mismatched in/out amounts. + const isLoading = inputIsLoading || outputIsLoading || isQuoteLoading || quoteParamsChanged const [hasLoadingTimedOut, setHasLoadingTimedOut] = useState(false) useEffect(() => { @@ -51,12 +57,20 @@ export function useFiatValuePriceImpact(): { priceImpact: Percent | undefined; i // Don't calculate price impact if trade is not set up (both trade assets are not set) if (!isTradeSetUp) return null + const stillLoading = isLoading && !hasLoadingTimedOut + + // While a fresh quote is loading, don't expose the stale value at all — consumers + // hide the percentage when `priceImpact` is undefined, leaving just the spinner. + if (stillLoading) { + return { priceImpact: undefined, isLoading: true } + } + const priceImpact = computeFiatValuePriceImpact( fiatValueInput ? FractionUtils.fractionLikeToFraction(fiatValueInput) : null, fiatValueOutput ? FractionUtils.fractionLikeToFraction(fiatValueOutput) : null, ) - return { priceImpact, isLoading: isLoading && !hasLoadingTimedOut } + return { priceImpact, isLoading: false } }, [isTradeSetUp, fiatValueInput, fiatValueOutput, isLoading, hasLoadingTimedOut]) } From 6354b1ae6e7a6acf7df1fcd5f25fcbf1bb756361 Mon Sep 17 00:00:00 2001 From: tenderdeve Date: Tue, 7 Jul 2026 18:04:34 +0530 Subject: [PATCH 3/3] fix(bridge): re-arm price-impact timeout on new quotes, not just token change The stale-value suppression relied on `hasLoadingTimedOut`, whose timeout only reset when the token pair changed. After the first 15s it stayed true, so later same-pair repricing exposed the stale price impact immediately instead of hiding it while the fresh quote loaded. Add `quoteParamsChanged` to the timeout effect so it re-arms for every new quote, while plain loading flicker still can't restart it (a stuck quote keeps timing out). Adds a regression test. --- .../useFiatValuePriceImpact.test.ts | 44 +++++++++++++++++++ .../usePriceImpact/useFiatValuePriceImpact.ts | 7 ++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/legacy/hooks/usePriceImpact/useFiatValuePriceImpact.test.ts b/apps/cowswap-frontend/src/legacy/hooks/usePriceImpact/useFiatValuePriceImpact.test.ts index 885803c18d0..25b398ee382 100644 --- a/apps/cowswap-frontend/src/legacy/hooks/usePriceImpact/useFiatValuePriceImpact.test.ts +++ b/apps/cowswap-frontend/src/legacy/hooks/usePriceImpact/useFiatValuePriceImpact.test.ts @@ -4,6 +4,7 @@ import { CurrencyAmount, Token } from '@cowprotocol/currency' import { act, renderHook } from '@testing-library/react' import { useDerivedTradeState } from 'modules/trade' +import { useTradeQuote } from 'modules/tradeQuote' import { useTradeUsdAmounts } from 'modules/usdAmount' import { useFiatValuePriceImpact } from './useFiatValuePriceImpact' @@ -17,6 +18,10 @@ jest.mock('modules/trade', () => ({ useDerivedTradeState: jest.fn(), })) +jest.mock('modules/tradeQuote', () => ({ + useTradeQuote: jest.fn(), +})) + jest.mock('modules/usdAmount', () => ({ useTradeUsdAmounts: jest.fn(), })) @@ -32,6 +37,7 @@ jest.mock('./logger', () => ({ const mockedUseDerivedTradeState = useDerivedTradeState as jest.MockedFunction const mockedUseTradeUsdAmounts = useTradeUsdAmounts as jest.MockedFunction +const mockedUseTradeQuote = useTradeQuote as jest.MockedFunction function createToken(symbol: string, address: string): Token { return new Token(ChainId.SEPOLIA, address, 18, symbol, symbol) @@ -52,6 +58,11 @@ describe('useFiatValuePriceImpact', () => { inputCurrencyAmount: CurrencyAmount.fromRawAmount(inputToken, 1), outputCurrencyAmount: CurrencyAmount.fromRawAmount(outputToken, 1), } as ReturnType) + + mockedUseTradeQuote.mockReturnValue({ + isLoading: false, + hasParamsChanged: false, + } as ReturnType) }) afterEach(() => { @@ -158,4 +169,37 @@ describe('useFiatValuePriceImpact', () => { expect(result.current).toEqual({ priceImpact: undefined, isLoading: false }) }) + + it('restarts the loading timeout when a new quote begins after timing out', () => { + mockedUseTradeUsdAmounts.mockReturnValue({ + inputAmount: { value: null, isLoading: true }, + outputAmount: { value: null, isLoading: true }, + }) + + const { result, rerender } = renderHook(() => useFiatValuePriceImpact()) + + expect(result.current).toEqual({ priceImpact: undefined, isLoading: true }) + + act(() => { + jest.advanceTimersByTime(15_000) + }) + + // Stale value stays suppressed only until the safety-valve timeout fires + expect(result.current).toEqual({ priceImpact: undefined, isLoading: false }) + + // A fresh quote for the same token pair must re-arm the timeout and suppress the stale value again + mockedUseTradeQuote.mockReturnValue({ + isLoading: true, + hasParamsChanged: true, + } as ReturnType) + rerender() + + expect(result.current).toEqual({ priceImpact: undefined, isLoading: true }) + + act(() => { + jest.advanceTimersByTime(15_000) + }) + + expect(result.current).toEqual({ priceImpact: undefined, isLoading: false }) + }) }) diff --git a/apps/cowswap-frontend/src/legacy/hooks/usePriceImpact/useFiatValuePriceImpact.ts b/apps/cowswap-frontend/src/legacy/hooks/usePriceImpact/useFiatValuePriceImpact.ts index ee3550161cd..f2bb79b364c 100644 --- a/apps/cowswap-frontend/src/legacy/hooks/usePriceImpact/useFiatValuePriceImpact.ts +++ b/apps/cowswap-frontend/src/legacy/hooks/usePriceImpact/useFiatValuePriceImpact.ts @@ -40,6 +40,11 @@ export function useFiatValuePriceImpact(): { priceImpact: Percent | undefined; i const isLoading = inputIsLoading || outputIsLoading || isQuoteLoading || quoteParamsChanged const [hasLoadingTimedOut, setHasLoadingTimedOut] = useState(false) + // Restart the safety-valve timeout on a token-pair change OR whenever a new quote begins + // (`quoteParamsChanged`). Keying it only off the token pair left `hasLoadingTimedOut` stuck + // true after the first 15s, so the stale-value suppression below never fired again for later + // same-pair repricing. Plain loading flicker (unchanged params) intentionally does not restart + // it, so a stuck quote still times out. useEffect(() => { logPriceImpact.debug(`Price impact timeout reset`) setHasLoadingTimedOut(false) @@ -51,7 +56,7 @@ export function useFiatValuePriceImpact(): { priceImpact: Percent | undefined; i }, PRICE_IMPACT_LOADING_TIMEOUT) return () => clearTimeout(timeoutId) - }, [isTradeSetUp, inputToken, outputToken]) + }, [isTradeSetUp, inputToken, outputToken, quoteParamsChanged]) return useSafeMemo(() => { // Don't calculate price impact if trade is not set up (both trade assets are not set)