diff --git a/__tests__/ui/perpsAccountPricing.test.ts b/__tests__/ui/perpsAccountPricing.test.ts index 5fb23dbf428..2a55ecef817 100644 --- a/__tests__/ui/perpsAccountPricing.test.ts +++ b/__tests__/ui/perpsAccountPricing.test.ts @@ -6,7 +6,6 @@ import { usdcMarkPx, } from '@/ui/views/DesktopPerps/components/AccountInfo/utils'; import { - hasNonPerpsPortfolioAssets, getStakedHypeAmount, computeStakingValue, computePerpsPortfolioValue, @@ -390,30 +389,3 @@ describe('computeAvailableBalance', () => { ).toBe(42); }); }); - -describe('hasNonPerpsPortfolioAssets', () => { - it.each(['delegated', 'undelegated', 'totalPendingWithdrawal'] as const)( - 'shows the breakdown for staking-only %s balances', - (field) => { - expect( - hasNonPerpsPortfolioAssets({ - spotState: { balances: [] }, - stakingSummary: { - delegated: '0', - undelegated: '0', - totalPendingWithdrawal: '0', - [field]: '2', - }, - }) - ).toBe(true); - } - ); - it('hides the breakdown for an empty account and keeps spot balances eligible', () => { - expect(hasNonPerpsPortfolioAssets({ stakingSummary: null })).toBe(false); - expect( - hasNonPerpsPortfolioAssets({ - spotState: { balances: [bal('USDC', 0, '1')] }, - }) - ).toBe(true); - }); -}); diff --git a/src/ui/views/DesktopPerps/components/ChartArea/components/ChartWrapper.tsx b/src/ui/views/DesktopPerps/components/ChartArea/components/ChartWrapper.tsx index ab7a99276bc..66300d810c4 100644 --- a/src/ui/views/DesktopPerps/components/ChartArea/components/ChartWrapper.tsx +++ b/src/ui/views/DesktopPerps/components/ChartArea/components/ChartWrapper.tsx @@ -257,6 +257,7 @@ export const ChartWrapper: React.FC = ({ coin={coin} interval={propInterval as any} pxDecimals={pxDecimals} + pxDecimalsReady={!!currentMarketData.markPx} isDarkTheme={isDarkTheme} locale={chartLocale} timezone={chartTimezone} diff --git a/src/ui/views/Perps/components/PerpsAccountCard.tsx b/src/ui/views/Perps/components/PerpsAccountCard.tsx index 49cad94c0ee..460c65e97af 100644 --- a/src/ui/views/Perps/components/PerpsAccountCard.tsx +++ b/src/ui/views/Perps/components/PerpsAccountCard.tsx @@ -15,7 +15,6 @@ import { isPortfolioAllZero, } from '../utils/perpsPortfolio'; import type { PortfolioPeriodKey } from '../utils/perpsPortfolio'; -import { hasNonPerpsPortfolioAssets } from '../utils/accountPricing'; import type { PerpsBreakdownMode } from '../utils/accountPricing'; import { PerpsQuoteAsset } from '../constants'; import { PerpsPortfolioChart } from './PerpsPortfolioChart'; @@ -193,23 +192,18 @@ export const PerpsAccountCard: React.FC = ({ const liveHeadline = viewState === 'zero' ? liveValue ?? 0 : 0; const headlineValue = viewState === 'data' ? displayValue ?? 0 : liveHeadline; - // No info icon in the empty/zero state (matches the Figma empty-state - // frame) or when there is nothing to break down — no spot or staking assets - // (mobile's hasNonPerpsAssets). isUserDataReady also guards the - // account-switch window where clearinghouseState still holds the previous - // account's numbers (see setCurrentPerpsAccount). + // The popover also explains the account mode, so it shows even when spot / + // staking are empty — only an all-zero Portfolio Value hides it (that also + // covers loading / error, where headlineValue is 0). isUserDataReady also + // guards the account-switch window where clearinghouseState still holds the + // previous account's numbers (see setCurrentPerpsAccount). const isUserDataReady = useRabbySelector((s) => s.perps.isUserDataReady); - // A boolean that flips on balance changes, not on price ticks. - const hasNonPerpsAssets = useRabbySelector((s) => - hasNonPerpsPortfolioAssets(s.perps) - ); const breakdownMode: PerpsBreakdownMode = isPortfolioMargin ? 'portfolioMargin' : isUnifiedAccount ? 'unified' : 'manual'; - const showBreakdown = - hasNonPerpsAssets && headlineValue > 0 && isUserDataReady; + const showBreakdown = headlineValue > 0 && isUserDataReady; const valueDisplay = useMemo(() => { if (viewState === 'loading') { diff --git a/src/ui/views/Perps/components/TradingViewIframeChart.tsx b/src/ui/views/Perps/components/TradingViewIframeChart.tsx index 89cbe2e5cf3..e7420d60e8c 100644 --- a/src/ui/views/Perps/components/TradingViewIframeChart.tsx +++ b/src/ui/views/Perps/components/TradingViewIframeChart.tsx @@ -20,6 +20,10 @@ import { } from '../weeklyCandles'; const BRIDGE_CHANNEL = 'rabby-tradingview-bridge-v1'; +// Upper bound on how long resolveSymbol waits for a price-derived pxDecimals. +// Past it the chart boots on the fallback precision and the late minTick +// override corrects it, as before. +const PX_DECIMALS_WAIT_MS = 3000; const DEFAULT_TRADINGVIEW_URL = process.env.DEBUG ? 'https://tradingview-test.vercel.app/' : 'https://tradingview.rabby.io/'; @@ -141,6 +145,10 @@ interface TradingViewIframeChartProps { coin: string; interval: PerpsInterval; pxDecimals: number; + // false while pxDecimals is still the price-less fallback; resolveSymbol + // holds its answer until it turns true so the axis never boots on a + // precision that is about to change + pxDecimalsReady?: boolean; isDarkTheme: boolean; locale: string; timezone: string; @@ -336,6 +344,7 @@ export const TradingViewIframeChart: React.FC = ({ coin, interval, pxDecimals, + pxDecimalsReady = true, isDarkTheme, locale, timezone, @@ -410,6 +419,7 @@ export const TradingViewIframeChart: React.FC = ({ coin, interval, pxDecimals, + pxDecimalsReady, isDarkTheme, locale, timezone, @@ -425,6 +435,7 @@ export const TradingViewIframeChart: React.FC = ({ coin, interval, pxDecimals, + pxDecimalsReady, isDarkTheme, locale, timezone, @@ -438,6 +449,7 @@ export const TradingViewIframeChart: React.FC = ({ coin, interval, pxDecimals, + pxDecimalsReady, isDarkTheme, locale, timezone, @@ -448,6 +460,13 @@ export const TradingViewIframeChart: React.FC = ({ onIntervalChange, ]); + // Declared after the stateRef sync so a woken resolveSymbol reads the + // current coin/pxDecimals. Each waiter settles itself once it is done. + const pxDecimalsWaitersRef = useRef(new Set<(force?: boolean) => boolean>()); + useEffect(() => { + pxDecimalsWaitersRef.current.forEach((settle) => settle()); + }, [coin, pxDecimals, pxDecimalsReady]); + useEffect(() => { const sdk = getPerpsSDK(); let chartNeedsRecovery = false; @@ -708,6 +727,34 @@ export const TradingViewIframeChart: React.FC = ({ return { ok: true }; }; + // TradingView samples pricescale once per resolveSymbol, so answering it + // with the price-less fallback makes the axis visibly re-tick when the + // first asset ctx lands (BTC: 1 decimal → 0). Wait while the requested + // symbol is still on that fallback — but only while it is still the + // parent's coin. Once the parent moves on, answer at once with the last + // precision seen for the requested symbol: never another coin's + // pricescale, and no stall ahead of the setSymbol that follows. + const resolvePxDecimals = (symbol: string) => + new Promise((resolve) => { + let symbolPxDecimals = stateRef.current.pxDecimals; + // Settling twice is harmless (the promise is already resolved), so + // the timeout is left to fire. + const settle = (force = false) => { + const current = stateRef.current; + const isRequestedCoin = current.coin === symbol; + if (isRequestedCoin) symbolPxDecimals = current.pxDecimals; + if (isRequestedCoin && !current.pxDecimalsReady && !force) { + return false; + } + pxDecimalsWaitersRef.current.delete(settle); + resolve(symbolPxDecimals); + return true; + }; + if (settle()) return; + pxDecimalsWaitersRef.current.add(settle); + setTimeout(() => settle(true), PX_DECIMALS_WAIT_MS); + }); + const handleMessage = async (event: MessageEvent) => { const message = event.data as BridgeMessage; if (!message || message.channel !== BRIDGE_CHANNEL) return; @@ -783,24 +830,38 @@ export const TradingViewIframeChart: React.FC = ({ }); break; case 'resolveSymbol': - respond(true, { - name: message.params?.symbol || stateRef.current.coin, - ticker: message.params?.symbol || stateRef.current.coin, - description: message.params?.symbol || stateRef.current.coin, - type: 'crypto', - session: '24x7', - timezone: stateRef.current.timezone, - // exchange: 'Hyperliquid', - // listed_exchange: 'Hyperliquid', - minmov: 1, - pricescale: 10 ** Math.max(stateRef.current.pxDecimals, 0), - has_intraday: true, - has_weekly_and_monthly: true, - supported_resolutions: SUPPORTED_RESOLUTIONS, - intraday_multipliers: ['1', '5', '15', '30', '60', '240', '480'], - data_status: 'streaming', - volume_precision: 2, - }); + { + // Bound before the wait: the answer describes the symbol that + // was asked for, not whatever the parent shows by then. + const symbol = message.params?.symbol || stateRef.current.coin; + const pxDecimals = await resolvePxDecimals(symbol); + respond(true, { + name: symbol, + ticker: symbol, + description: symbol, + type: 'crypto', + session: '24x7', + timezone: stateRef.current.timezone, + // exchange: 'Hyperliquid', + // listed_exchange: 'Hyperliquid', + minmov: 1, + pricescale: 10 ** Math.max(pxDecimals, 0), + has_intraday: true, + has_weekly_and_monthly: true, + supported_resolutions: SUPPORTED_RESOLUTIONS, + intraday_multipliers: [ + '1', + '5', + '15', + '30', + '60', + '240', + '480', + ], + data_status: 'streaming', + volume_precision: 2, + }); + } break; case 'getBars': { diff --git a/src/ui/views/Perps/utils/accountPricing.ts b/src/ui/views/Perps/utils/accountPricing.ts index 36f367ca50e..0a597863a52 100644 --- a/src/ui/views/Perps/utils/accountPricing.ts +++ b/src/ui/views/Perps/utils/accountPricing.ts @@ -273,11 +273,3 @@ export const computePortfolioBreakdownValues = ( stakingValue: null, }; }; - -/** Balance-only selector: opening the breakdown does not depend on price ticks. */ -export const hasNonPerpsPortfolioAssets = (perps: { - spotState?: { balances?: Pick[] }; - stakingSummary?: StakingSummaryAmounts | null; -}): boolean => - !!perps.spotState?.balances?.some((balance) => Number(balance.total) > 0) || - new BigNumber(getStakedHypeAmount(perps.stakingSummary)).gt(0);