refactor: Fix perps connectivity issues after idle#41728
Conversation
✨ Files requiring CODEOWNER review ✨👨🔧 @MetaMask/perps (5 files, +886 -24)
|
| // Refetch positions when tab becomes visible (catch changes made elsewhere) | ||
| useEffect(() => { | ||
| const handleVisibility = async () => { | ||
| if (document.visibilityState === 'visible' && selectedAddress) { | ||
| try { | ||
| const positions = await submitRequestToBackground<Position[]>( | ||
| 'perpsGetPositions', | ||
| [{ skipCache: true }], | ||
| ); | ||
| getPerpsStreamManager().pushPositionsWithOverrides(positions); | ||
| } catch (e) { | ||
| console.warn('[Perps] Visibility refetch failed:', e); | ||
| } | ||
| } | ||
| }; | ||
| document.addEventListener('visibilitychange', handleVisibility); | ||
| return () => | ||
| document.removeEventListener('visibilitychange', handleVisibility); | ||
| }, [selectedAddress]); | ||
|
|
There was a problem hiding this comment.
We no longer need this since it's now being handled in reconnect on focus logic
Builds ready [b9a719c]
⚡ Performance Benchmarks (Total: 🟢 7 pass · 🟡 12 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
…ndering race condition
Builds ready [f00fa7f]
⚡ Performance Benchmarks (Total: 🟢 7 pass · 🟡 12 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
| clientConfig: { | ||
| fallbackHip3Enabled: true, | ||
| fallbackHip3AllowlistMarkets: [], | ||
| fallbackHip3AllowlistMarkets: ['xyz:*'], |
There was a problem hiding this comment.
defaulting this to xyz:* here to match the productionDefault, and to prevent a race condition between launch darkly populating this, and market data arriving from the hyperliquid provider, which would break the UI (hip3 markets would load without price data)
This is a simple pragmatic fix that dramatically makes market lists more stable. I don't think we have plans to add more markets in the near future. This also doesn't prevent us from blocklisting xyz or from completely turning off hip3 via launch darkly.
There was a problem hiding this comment.
Makes sense. Since this fallback is intentionally matching the production default to avoid the LaunchDarkly/preload race, can we capture that directly in code here with a short comment and/or a focused test?
Hardcoding ['xyz:*'] reads like a rollout override. A short explanation that this is something like this is meant to align fallback behavior with the production default and prevent partial HIP-3 market hydration would make the intent easier for future devs to understand.
Builds ready [1be9924]
⚡ Performance Benchmarks (Total: 🟢 7 pass · 🟡 12 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
Builds ready [9b9a4e8]
⚡ Performance Benchmarks (Total: 🟢 7 pass · 🟡 12 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 402d022. Configure here.
402d022 to
4cc4384
Compare
|
Builds ready [1a3bf2d]
⚡ Performance Benchmarks (Total: 🟢 7 pass · 🟡 12 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
Automated Review — PR #41728
SummaryThis PR centralizes perps WebSocket recovery in the background The approach is sound — moving recovery to the background is the right architectural call. The hydration sequence token ( Note: The balance double-count fix (AC2) was reverted in commit 266d976 and is not present in the final diff. Full review detailsRecipe Coverage
Overall recipe coverage: 2/6 ACs PROVEN
Prior ReviewsNo prior reviews with CHANGES_REQUESTED found. Acceptance Criteria Validation
Code Quality
Fix Quality
Live Validation
Correctness
Static Analysis
Mobile Comparison
Architecture & Domain
Risk AssessmentMEDIUM — The PR adds background-triggered REST hydration and WS reconnect logic that runs automatically on state transitions. While well-guarded against races, the hydration makes 4 REST calls (markets, positions, orders, account) after every reconnect. Under flaky network conditions with rapid disconnect/reconnect cycles, this could create burst pressure on the Hyperliquid API. The Recommended ActionCOMMENT The PR is well-implemented and achieves its goals. Two suggestions for consideration:
Line comments (5 comments: 1 suggestion, 4 nitpick)
Recipe (N/A){
"title": "PR #41728 — Verify perps connectivity recovery and HIP-3 market stability",
"validate": {
"workflow": {
"pre_conditions": ["wallet.unlocked", "perps.feature_enabled"],
"entry": "setup-navigate-perps",
"nodes": {
"setup-navigate-perps": {
"action": "call",
"ref": "perps/navigate-perps-tab",
"next": "setup-wait-perps-tab"
},
"setup-wait-perps-tab": {
"action": "wait_for",
"test_id": "account-overview__perps-tab",
"timeout_ms": 10000,
"next": "ac3-screenshot-perps-tab"
},
"ac3-screenshot-perps-tab": {
"action": "screenshot",
"filename": "evidence-ac3-perps-tab-hip3.png",
"next": "ac3-navigate-eth-market"
},
"ac3-navigate-eth-market": {
"action": "call",
"ref": "perps/navigate-to-market-detail",
"params": { "symbol": "ETH" },
"next": "ac3-wait-eth-price"
},
"ac3-wait-eth-price": {
"action": "wait_for",
"test_id": "perps-market-detail-price",
"timeout_ms": 15000,
"next": "ac3-assert-eth-price"
},
"ac3-assert-eth-price": {
"action": "ext_check_dom",
"test_id": "perps-market-detail-price",
"visible": true,
"assert": {
"operator": "not_contains",
"field": "text",
"value": "$---"
},
"next": "ac3-screenshot-eth-price"
},
"ac3-screenshot-eth-price": {
"action": "screenshot",
"filename": "evidence-ac3-eth-price.png",
"next": "ac4-navigate-btc"
},
"ac4-navigate-btc": {
"action": "call",
"ref": "perps/navigate-to-market-detail",
"params": { "symbol": "BTC" },
"next": "ac4-wait-btc"
},
"ac4-wait-btc": {
"action": "wait_for",
"test_id": "perps-market-detail-price",
"timeout_ms": 15000,
"next": "ac4-assert-btc-price"
},
"ac4-assert-btc-price": {
"action": "ext_check_dom",
"test_id": "perps-market-detail-price",
"visible": true,
"assert": {
"operator": "not_contains",
"field": "text",
"value": "$---"
},
"next": "ac4-navigate-back-eth"
},
"ac4-navigate-back-eth": {
"action": "call",
"ref": "perps/navigate-to-market-detail",
"params": { "symbol": "ETH" },
"next": "ac4-wait-eth-again"
},
"ac4-wait-eth-again": {
"action": "wait_for",
"test_id": "perps-market-detail-price",
"timeout_ms": 15000,
"next": "ac4-assert-eth-stable"
},
"ac4-assert-eth-stable": {
"action": "ext_check_dom",
"test_id": "perps-market-detail-price",
"visible": true,
"assert": {
"operator": "not_contains",
"field": "text",
"value": "$---"
},
"next": "ac4-navigate-atom"
},
"ac4-navigate-atom": {
"action": "call",
"ref": "perps/navigate-to-market-detail",
"params": { "symbol": "ATOM" },
"next": "ac4-wait-atom"
},
"ac4-wait-atom": {
"action": "wait_for",
"test_id": "perps-market-detail-price",
"timeout_ms": 15000,
"next": "ac4-assert-atom-price"
},
"ac4-assert-atom-price": {
"action": "ext_check_dom",
"test_id": "perps-market-detail-price",
"visible": true,
"assert": {
"operator": "not_contains",
"field": "text",
"value": "$---"
},
"next": "ac4-screenshot-rapid-nav"
},
"ac4-screenshot-rapid-nav": {
"action": "screenshot",
"filename": "evidence-ac4-rapid-navigation-atom.png",
"next": "ac1-eval-connection-state"
},
"ac1-eval-connection-state": {
"action": "eval_async",
"expression": "(async function(){ var state = await stateHooks.submitRequestToBackground('perpsGetConnectionState', []); return JSON.stringify({ connectionState: state }); })()",
"assert": {
"operator": "eq",
"field": "connectionState",
"value": "connected"
},
"next": "ac1-screenshot-connection"
},
"ac1-screenshot-connection": {
"action": "screenshot",
"filename": "evidence-ac1-connection-state.png",
"next": "ac5-eval-perps-check-health"
},
"ac5-eval-perps-check-health": {
"action": "eval_async",
"expression": "(async function(){ try { await stateHooks.submitRequestToBackground('perpsCheckHealth', []); return JSON.stringify({ healthCheckAvailable: true }); } catch(e) { return JSON.stringify({ healthCheckAvailable: false, error: e.message }); } })()",
"assert": {
"operator": "eq",
"field": "healthCheckAvailable",
"value": true
},
"next": "ac5-screenshot-health-check"
},
"ac5-screenshot-health-check": {
"action": "screenshot",
"filename": "evidence-ac5-health-check-api.png",
"next": "ac6-eval-reconnect-api"
},
"ac6-eval-reconnect-api": {
"action": "eval_async",
"expression": "(async function(){ try { var state = await stateHooks.submitRequestToBackground('perpsGetConnectionState', []); return JSON.stringify({ reconnectApiAvailable: true, currentState: state }); } catch(e) { return JSON.stringify({ reconnectApiAvailable: false, error: e.message }); } })()",
"assert": {
"operator": "eq",
"field": "reconnectApiAvailable",
"value": true
},
"next": "ac6-screenshot-reconnect"
},
"ac6-screenshot-reconnect": {
"action": "screenshot",
"filename": "evidence-ac6-reconnect-api.png",
"next": "ac3-eval-hip3-markets"
},
"ac3-eval-hip3-markets": {
"action": "eval_async",
"expression": "(async function(){ var r = await stateHooks.submitRequestToBackground('perpsGetMarketDataWithPrices', []); var hip3 = r ? r.filter(function(m){ return m.symbol.indexOf(':') !== -1; }) : []; return JSON.stringify({ hip3Count: hip3.length, hip3HasPrices: hip3.filter(function(m){ return m.lastPrice && m.lastPrice !== '0'; }).length }); })()",
"assert": {
"operator": "gt",
"field": "hip3Count",
"value": 0
},
"next": "ac3-screenshot-hip3-eval"
},
"ac3-screenshot-hip3-eval": {
"action": "screenshot",
"filename": "evidence-ac3-hip3-markets-eval.png",
"next": "gate-done"
},
"gate-done": {
"action": "end",
"status": "pass",
"message": "All testable ACs verified. AC3: HIP-3 markets present with prices. AC4: rapid navigation stable. AC1/AC5/AC6: API availability confirmed."
}
}
}
}
} |
Visual EvidenceEvidence/evidence Ac1 Connection StateEvidence/evidence Ac3 Eth PriceEvidence/evidence Ac3 Hip3 Markets EvalEvidence/evidence Ac3 Perps Tab Hip3Evidence/evidence Ac4 Rapid Navigation AtomEvidence/evidence Ac5 Health Check ApiEvidence/evidence Ac6 Reconnect ApiEvidence/evidence BaselineReview |












Description
This PR improves Perps reliability after idle time, sleep/wake, or flaky network, and reduces races that left the UI empty, wrong, or missing HIP-3 markets when navigating quickly or when remote config arrived late. Also addresses an issue with the total balance calculation double counting PnL and flickering between values.
Quiet WebSocket / empty UI — After long idle, sleep, or connectivity loss, users could see an empty Perps state (e.g. no balance/positions) until switching accounts or similar. We improve recovery by centralizing connection handling in the background and hydrating key REST snapshots after a real disconnect/reconnect cycle.
Wrong headline balance on load — Total balance could briefly show too high when unrealized PnL was applied twice in the displayed “account value” path; the calculation is corrected so equity matches Hyperliquid’s notion of account value.
HIP-3 markets missing or wrong on first paint — LaunchDarkly could deliver the HIP-3 allowlist after the provider first built its validated DEX list, so the first cached universe could be main DEX only and navigation could feel broken. We add a stable extension-side default so HIP-3 isn’t blocked on LD winning the race first.
Changes:
Background — PerpsStreamBridge + controller wiring
Subscribes to subscribeToConnectionState: on transition back to connected after a disconnect, runs REST hydration (markets, positions, orders, account) and pushes updates to the UI stream.
Exposes perpsCheckHealth: if the WS is explicitly disconnected, triggers reconnect() (errors swallowed).
Subscribes to ConnectivityController:stateChange: on offline → online, if the WS is disconnected, triggers reconnect().
Subscribes to PerpsController:stateChange: when cachedMarketDataByProvider updates (e.g. background preload after HIP-3 config changes), emits markets to the UI so PerpsStreamManager stays aligned without a Redux sync hook.
UI — PerpsLayout
Visibility: after the tab was hidden ≥ 30s, on visible again, calls perpsCheckHealth (fire-and-forget).
Stream prewarm: streamManager.prewarm() while the layout is mounted and manager is ready, cleanupPrewarm() on unmount — avoids channels dropping to zero subscribers during in-app navigation and refetching with worse cached data.
Controller init
fallbackHip3AllowlistMarkets: ['xyz:*'] (with fallbackHip3Enabled: true) so HIP-3 markets are included from the first construction path aligned with production defaults; LD can still refine/overrides over time.
Balance UI
Corrects the account value / total balance presentation so unrealizedPnl is not double-counted against Hyperliquid’s totalBalance / account value.
Keeps a loading skeleton for the balance control bar where appropriate so users aren’t misled by stale or partial numbers during fetch.
Changelog
CHANGELOG entry: Improve perps stream connectivity and fix race conditions between hyperliquid and launch darkly
Related issues
Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2949
Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2958
Manual testing steps
Go to Perps tab
Navigate quickly between assets, particularly hip3 assets
Assets data should remain stable and should not have rendering issue
Check total balance, ensure it matches with mobile
Screenshots/Recordings
Before
After
Pre-merge author checklist
Pre-merge reviewer checklist
Note
Medium Risk
Touches Perps connectivity/reconnect paths and adds background-triggered REST hydration and new RPC methods, which can affect rate limits, data freshness, and reconnection behavior under flaky networks.
Overview
Improves Perps recovery after idle/network changes by extending
PerpsStreamBridgeto subscribe to WebSocket connection state, connectivity changes, andPerpsController:stateChange, and to emitconnectionStateplus updated cached market data (markets) to the UI with timestamp-based de-duping.Adds background health/rehydration behaviors: a new
perpsCheckHealthRPC triggersreconnect()when the WS is disconnected, offline→online transitions can also trigger reconnect, and a disconnected→connected transition kicks off a staggered REST hydration (getMarketDataWithPrices,getPositions(skipCache),getOpenOrders,getAccountState) with guards against stale/overlapping runs.Updates wiring and UI to use these mechanisms:
metamask-controller.jspasses controller/connectivity state listeners into the bridge,PerpsLayoutpingsperpsCheckHealthwhen a tab becomes visible after 30s hidden, the per-asset page removes its visibility-based positions refetch,PerpsStreamManagernow consumesmarketsstream updates, and Perps controller init setsfallbackHip3AllowlistMarketsto['xyz:*']by default. Tests are expanded to cover the new subscriptions, teardown, reconnect, and hydration flows.Reviewed by Cursor Bugbot for commit 1a3bf2d. Bugbot is set up for automated code reviews on this repo. Configure here.