Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 0 additions & 28 deletions __tests__/ui/perpsAccountPricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
usdcMarkPx,
} from '@/ui/views/DesktopPerps/components/AccountInfo/utils';
import {
hasNonPerpsPortfolioAssets,
getStakedHypeAmount,
computeStakingValue,
computePerpsPortfolioValue,
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ export const ChartWrapper: React.FC<ChartWrapperProps> = ({
coin={coin}
interval={propInterval as any}
pxDecimals={pxDecimals}
pxDecimalsReady={!!currentMarketData.markPx}
isDarkTheme={isDarkTheme}
locale={chartLocale}
timezone={chartTimezone}
Expand Down
18 changes: 6 additions & 12 deletions src/ui/views/Perps/components/PerpsAccountCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -193,23 +192,18 @@ export const PerpsAccountCard: React.FC<PerpsAccountCardProps> = ({
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') {
Expand Down
97 changes: 79 additions & 18 deletions src/ui/views/Perps/components/TradingViewIframeChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -336,6 +344,7 @@ export const TradingViewIframeChart: React.FC<TradingViewIframeChartProps> = ({
coin,
interval,
pxDecimals,
pxDecimalsReady = true,
isDarkTheme,
locale,
timezone,
Expand Down Expand Up @@ -410,6 +419,7 @@ export const TradingViewIframeChart: React.FC<TradingViewIframeChartProps> = ({
coin,
interval,
pxDecimals,
pxDecimalsReady,
isDarkTheme,
locale,
timezone,
Expand All @@ -425,6 +435,7 @@ export const TradingViewIframeChart: React.FC<TradingViewIframeChartProps> = ({
coin,
interval,
pxDecimals,
pxDecimalsReady,
isDarkTheme,
locale,
timezone,
Expand All @@ -438,6 +449,7 @@ export const TradingViewIframeChart: React.FC<TradingViewIframeChartProps> = ({
coin,
interval,
pxDecimals,
pxDecimalsReady,
isDarkTheme,
locale,
timezone,
Expand All @@ -448,6 +460,13 @@ export const TradingViewIframeChart: React.FC<TradingViewIframeChartProps> = ({
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;
Expand Down Expand Up @@ -708,6 +727,34 @@ export const TradingViewIframeChart: React.FC<TradingViewIframeChartProps> = ({
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<number>((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;
Expand Down Expand Up @@ -783,24 +830,38 @@ export const TradingViewIframeChart: React.FC<TradingViewIframeChartProps> = ({
});
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':
{
Expand Down
8 changes: 0 additions & 8 deletions src/ui/views/Perps/utils/accountPricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SpotBalance, 'total'>[] };
stakingSummary?: StakingSummaryAmounts | null;
}): boolean =>
!!perps.spotState?.balances?.some((balance) => Number(balance.total) > 0) ||
new BigNumber(getStakedHypeAmount(perps.stakingSummary)).gt(0);
Loading