Skip to content

Commit f5e5f6d

Browse files
Cherry-picking commits from main to release/7.81.2-ota for PR #31801 (#31935)
- perf(predict): fix JS-thread FPS collapse on market details with live prices cp-7.82.0 (#31801) ## **Description** This PR fixes severe JS-thread performance degradation on the Predict market details screen (`PredictMarketDetails` / `PredictGameDetailsContent`). When opening a market with a live price feed — most noticeably a live soccer game — the JS thread FPS would collapse to near 0, making the screen unusable. **Reason for the change:** The live Polymarket WebSocket price stream drives high-frequency updates. The code was (1) doing an `O(subscriptions × tokens)` `new Set(key.split(','))` allocation on *every* incoming price message, and (2) re-rendering the entire details screen on every single message because nothing was throttled or memoized. Profiling showed `performWorkOnRoot` (React render) consuming ~70% of the trace, with `new Set()`/`Object.assign`/`JSON.stringify` allocation churn dominating self-time. **Improvements:** - **`WebSocketManager`** - Precompute the parsed `Set<tokenId>` per subscription once at subscribe time instead of rebuilding it (`new Set(key.split(','))`) on every message. This removes the single largest self-time hotspot. - Coalesce/throttle price emission with a leading + trailing edge (250ms, mirroring the existing orderbook throttle) so subscribers are notified at most ~4×/sec instead of at the full WebSocket message rate. - Clean up the new timer/buffer/Set state on disconnect and full cleanup. - **`useLiveMarketPrices`** — skip the state update entirely when an incoming tick carries no actual value change (`price`/`bestBid`/`bestAsk`), preventing redundant re-renders. - **`usePredictPrices`** — memoize the per-render `JSON.stringify(queries)` so it only runs when the queries change. - **`useOpenOutcomes`** - Disable the live price subscription for game markets, where its result is never rendered (the game branch runs its own subscriptions). Removed a redundant full-screen re-render source. - Preserve object identity for outcomes/tokens whose computed price did not change, so only rows that actually changed get a new reference. - **`PredictMarketDetails`** — memoize `handleBackPress`/`handleBuyPress`/`handleClaimPress` so stable callbacks no longer defeat child memoization. - **`PredictGameDetailsContent`** and **`PredictMarketOutcome`** — wrap in `React.memo` so a parent re-render no longer re-renders these subtrees/rows when their props are unchanged. **Result:** JS-thread FPS went from ~0 → mid-50s on live game events, and regular markets from ~25–35 → comparable smoothness, with re-renders now scoped to the components whose data actually changed. ## **Changelog** CHANGELOG entry: Fixed severe performance degradation on the Predict market details screen for markets with live price updates. ## **Related issues** Fixes: [PRED-1014](https://consensyssoftware.atlassian.net/browse/PRED-1014) ## **Manual testing steps** ```gherkin Feature: Predict market details performance with live prices Scenario: user opens a live game market Given a live soccer game market is available in Predict And the JS-thread FPS monitor / performance overlay is enabled When the user opens the PredictGameDetailsContent screen Then the screen remains responsive And the JS-thread FPS stays in a healthy range (not collapsing toward 0) Scenario: user opens a regular market with live prices Given a regular open market with live price updates is available in Predict When the user opens the PredictMarketDetails screen Then the outcomes list updates live without re-rendering unchanged rows And the JS-thread FPS stays in a healthy range Scenario: live prices still update correctly Given the user is on a market details screen When the underlying token prices change over the WebSocket feed Then the displayed prices/odds update to reflect the new values ``` ## **Screenshots/Recordings** ### **Before** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/1cc444f5-a693-4584-80f8-072305a3147c ### **After** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/1fe95b3c-1fb4-4fa0-a794-8450cbe08f88 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [PRED-1014]: https://consensyssoftware.atlassian.net/browse/PRED-1014?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [716f9a8](716f9a8) [PRED-1014]: https://consensyssoftware.atlassian.net/browse/PRED-1014?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: Luis Taniça <matallui@gmail.com>
1 parent 1f49900 commit f5e5f6d

8 files changed

Lines changed: 264 additions & 98 deletions

File tree

app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
TextVariant,
1313
} from '@metamask/design-system-react-native';
1414
import { useTailwind } from '@metamask/design-system-twrnc-preset';
15-
import React, { useCallback, useMemo } from 'react';
15+
import React, { memo, useCallback, useMemo } from 'react';
1616
import { Pressable, RefreshControl, ScrollView } from 'react-native';
1717
import {
1818
SafeAreaView,
@@ -37,7 +37,9 @@ import { PREDICT_GAME_DETAILS_CONTENT_TEST_IDS } from './PredictGameDetailsConte
3737

3838
const CHIPS_STICKY_INDEX = 2;
3939

40-
const PredictGameDetailsContent: React.FC<PredictGameDetailsContentProps> = ({
40+
const PredictGameDetailsContentComponent: React.FC<
41+
PredictGameDetailsContentProps
42+
> = ({
4143
market,
4244
onBack,
4345
onRefresh,
@@ -231,4 +233,10 @@ const PredictGameDetailsContent: React.FC<PredictGameDetailsContentProps> = ({
231233
);
232234
};
233235

236+
// Memoized so a parent (PredictMarketDetails) re-render driven by its own live
237+
// subscriptions does not re-render this entire subtree when our props are
238+
// unchanged. The screen's live odds updates are driven by this component's own
239+
// hooks instead.
240+
const PredictGameDetailsContent = memo(PredictGameDetailsContentComponent);
241+
234242
export default PredictGameDetailsContent;

app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from '@metamask/design-system-react-native';
99
import { useTailwind } from '@metamask/design-system-twrnc-preset';
1010
import { NavigationProp, useNavigation } from '@react-navigation/native';
11-
import React from 'react';
11+
import React, { memo } from 'react';
1212
import { Image, View } from 'react-native';
1313
import { strings } from '../../../../../../locales/i18n';
1414
import Button, {
@@ -50,7 +50,7 @@ interface PredictMarketOutcomeProps {
5050

5151
const MAX_LABEL_LENGTH = 6;
5252

53-
const PredictMarketOutcome: React.FC<PredictMarketOutcomeProps> = ({
53+
const PredictMarketOutcomeComponent: React.FC<PredictMarketOutcomeProps> = ({
5454
market,
5555
outcome,
5656
entryPoint = PredictEventValues.ENTRY_POINT.PREDICT_FEED,
@@ -221,4 +221,9 @@ const PredictMarketOutcome: React.FC<PredictMarketOutcomeProps> = ({
221221
);
222222
};
223223

224+
// Memoized so that in a live-updating outcomes list only the rows whose
225+
// `outcome` reference actually changed re-render. Relies on useOpenOutcomes
226+
// preserving identity for unchanged outcomes/tokens.
227+
const PredictMarketOutcome = memo(PredictMarketOutcomeComponent);
228+
224229
export default PredictMarketOutcome;

app/components/UI/Predict/hooks/useLiveMarketPrices.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ export const useLiveMarketPrices = (
7474

7575
const isMountedRef = useRef(true);
7676
const tokenIdsRef = useRef(tokenIds);
77+
// Mirrors the latest `prices` map so we can detect no-op ticks without
78+
// adding `prices` to `handlePriceUpdates`' dependencies. Kept in sync inside
79+
// every state updater below.
80+
const pricesRef = useRef(prices);
7781

7882
// Use JSON.stringify to avoid key collisions if token IDs contain commas
7983
const tokenIdsKey = useMemo(
@@ -95,11 +99,30 @@ export const useLiveMarketPrices = (
9599
liveMarketPriceCache.set(update.tokenId, { price: update, updatedAt });
96100
});
97101

102+
// Skip the state update entirely when none of the incoming values differ
103+
// from what we already have. This prevents a re-render of the whole
104+
// subscriber tree for redundant ticks that carry no visible change.
105+
const current = pricesRef.current;
106+
const hasChange = updates.some((update) => {
107+
const existing = current.get(update.tokenId);
108+
return (
109+
!existing ||
110+
existing.price !== update.price ||
111+
existing.bestBid !== update.bestBid ||
112+
existing.bestAsk !== update.bestAsk
113+
);
114+
});
115+
116+
if (!hasChange) {
117+
return;
118+
}
119+
98120
setPrices((prevPrices) => {
99121
const newPrices = new Map(prevPrices);
100122
updates.forEach((update) => {
101123
newPrices.set(update.tokenId, update);
102124
});
125+
pricesRef.current = newPrices;
103126
return newPrices;
104127
});
105128

@@ -125,6 +148,7 @@ export const useLiveMarketPrices = (
125148
nextPrices.set(tokenId, cachedPrice);
126149
}
127150
});
151+
pricesRef.current = nextPrices;
128152
return nextPrices;
129153
});
130154

app/components/UI/Predict/hooks/usePredictPrices.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useRef, useState } from 'react';
1+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
import Engine from '../../../../core/Engine';
33
import Logger from '../../../../util/Logger';
44
import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger';
@@ -62,7 +62,10 @@ export const usePredictPrices = (
6262
}
6363
}, [enabled]);
6464

65-
const queriesKey = JSON.stringify(queries);
65+
// Memoize so the (potentially large) serialization only runs when the
66+
// queries array identity actually changes, not on every re-render driven
67+
// by live price ticks.
68+
const queriesKey = useMemo(() => JSON.stringify(queries), [queries]);
6669

6770
const fetchPrices = useCallback(async () => {
6871
if (!enabled) {

app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2268,6 +2268,9 @@ describe('WebSocketManager', () => {
22682268
],
22692269
timestamp: '2025-01-12T12:00:01Z',
22702270
});
2271+
// Price emission is throttled: the first message flushes immediately
2272+
// (leading edge) and the second is coalesced into the trailing flush.
2273+
jest.advanceTimersByTime(250);
22712274

22722275
expect(throwingCallback).toHaveBeenCalledTimes(2);
22732276
expect(healthyCallback).toHaveBeenCalledTimes(2);
@@ -2311,7 +2314,7 @@ describe('WebSocketManager', () => {
23112314
context: {
23122315
name: 'WebSocketManager',
23132316
data: {
2314-
method: 'handleMarketMessage',
2317+
method: 'flushMarketPriceUpdates',
23152318
subscriptionKey: 'token1',
23162319
},
23172320
},

app/components/UI/Predict/providers/polymarket/WebSocketManager.ts

Lines changed: 93 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const RTDS_CRYPTO_PRICES_CHAINLINK_TOPIC = 'crypto_prices_chainlink';
3232
const RTDS_PING_INTERVAL_MS = 5000;
3333
const DEFAULT_THROTTLE_INTERVAL_MS = 16;
3434
const ORDERBOOK_EMIT_THROTTLE_MS = 250;
35+
const MARKET_PRICE_EMIT_THROTTLE_MS = 250;
3536

3637
const HEARTBEAT_CHECK_INTERVAL_MS = 5000;
3738
const MARKET_STALE_THRESHOLD_MS = 60000;
@@ -90,7 +91,16 @@ export class WebSocketManager {
9091

9192
private gameSubscriptions: Map<string, Set<GameUpdateCallback>> = new Map();
9293
private priceSubscriptions: Map<string, Set<PriceUpdateCallback>> = new Map();
94+
// Parsed token-id set per subscription key, precomputed once at subscribe
95+
// time so the high-frequency message handler never re-runs
96+
// `key.split(',')` + `new Set(...)` per message.
97+
private priceSubscriptionTokenSets: Map<string, Set<string>> = new Map();
9398
private marketPriceCache: Map<string, PriceUpdate> = new Map();
99+
// Coalesced price emission: token ids that changed since the last flush, plus
100+
// the leading/trailing throttle timer. Avoids re-rendering subscribers at the
101+
// full WebSocket message rate.
102+
private marketPricePendingTokenIds: Set<string> = new Set();
103+
private marketPriceEmitTimer: ReturnType<typeof setTimeout> | null = null;
94104
private orderbookSubscriptions: Map<string, Set<OrderbookCallback>> =
95105
new Map();
96106
private orderbookState: Map<
@@ -385,6 +395,10 @@ export class WebSocketManager {
385395
if (!callbacks) {
386396
callbacks = new Set();
387397
this.priceSubscriptions.set(subscriptionKey, callbacks);
398+
this.priceSubscriptionTokenSets.set(
399+
subscriptionKey,
400+
new Set(subscriptionKey.split(',').filter(Boolean)),
401+
);
388402
}
389403
callbacks.add(callback);
390404

@@ -425,6 +439,7 @@ export class WebSocketManager {
425439
subscriptionCallbacks.delete(callback);
426440
if (subscriptionCallbacks.size === 0) {
427441
this.priceSubscriptions.delete(subscriptionKey);
442+
this.priceSubscriptionTokenSets.delete(subscriptionKey);
428443
const remainingPriceTokenIds = this.getSubscribedMarketTokenIds();
429444
const tokenIdsToUnsubscribe = tokenIds.filter(
430445
(tokenId) =>
@@ -580,6 +595,74 @@ export class WebSocketManager {
580595
};
581596
}
582597

598+
/**
599+
* Throttle market price fan-out with a leading + trailing edge, mirroring
600+
* {@link scheduleOrderbookEmit}. The first change in a window is delivered
601+
* immediately; subsequent changes within the window are batched and flushed
602+
* once when the window closes.
603+
*/
604+
private scheduleMarketPriceEmit(): void {
605+
if (this.marketPriceEmitTimer === null) {
606+
this.flushMarketPriceUpdates();
607+
this.marketPriceEmitTimer = setTimeout(() => {
608+
this.marketPriceEmitTimer = null;
609+
if (this.marketPricePendingTokenIds.size > 0) {
610+
this.flushMarketPriceUpdates();
611+
}
612+
}, MARKET_PRICE_EMIT_THROTTLE_MS);
613+
}
614+
}
615+
616+
private flushMarketPriceUpdates(): void {
617+
if (this.marketPricePendingTokenIds.size === 0) {
618+
return;
619+
}
620+
621+
const changedTokenIds = this.marketPricePendingTokenIds;
622+
this.marketPricePendingTokenIds = new Set();
623+
624+
this.priceSubscriptions.forEach((callbacks, key) => {
625+
if (callbacks.size === 0) {
626+
return;
627+
}
628+
const subscribedTokenIds = this.priceSubscriptionTokenSets.get(key);
629+
if (!subscribedTokenIds) {
630+
return;
631+
}
632+
633+
const relevantUpdates: PriceUpdate[] = [];
634+
changedTokenIds.forEach((tokenId) => {
635+
if (subscribedTokenIds.has(tokenId)) {
636+
const cached = this.marketPriceCache.get(tokenId);
637+
if (cached) {
638+
relevantUpdates.push(cached);
639+
}
640+
}
641+
});
642+
643+
if (relevantUpdates.length === 0) {
644+
return;
645+
}
646+
647+
callbacks.forEach((callback) => {
648+
try {
649+
callback(relevantUpdates);
650+
} catch (error) {
651+
DevLogger.log('WebSocketManager: Market price subscriber failed', {
652+
error,
653+
subscriptionKey: key,
654+
});
655+
Logger.error(
656+
this.toError(error),
657+
this.getErrorContext('flushMarketPriceUpdates', 'market', {
658+
subscriptionKey: key,
659+
}),
660+
);
661+
}
662+
});
663+
});
664+
}
665+
583666
private emitOrderbookSnapshot(tokenId: string): void {
584667
const cached = this.orderbookState.get(tokenId);
585668
const callbacks = this.orderbookSubscriptions.get(tokenId);
@@ -737,36 +820,12 @@ export class WebSocketManager {
737820

738821
updates.forEach((update) => {
739822
this.marketPriceCache.set(update.tokenId, update);
823+
this.marketPricePendingTokenIds.add(update.tokenId);
740824
});
741825

742-
this.priceSubscriptions.forEach((callbacks, key) => {
743-
const subscribedTokenIds = new Set(key.split(','));
744-
const relevantUpdates = updates.filter((u) =>
745-
subscribedTokenIds.has(u.tokenId),
746-
);
747-
748-
if (relevantUpdates.length > 0) {
749-
callbacks.forEach((callback) => {
750-
try {
751-
callback(relevantUpdates);
752-
} catch (error) {
753-
DevLogger.log(
754-
'WebSocketManager: Market price subscriber failed',
755-
{
756-
error,
757-
subscriptionKey: key,
758-
},
759-
);
760-
Logger.error(
761-
this.toError(error),
762-
this.getErrorContext('handleMarketMessage', 'market', {
763-
subscriptionKey: key,
764-
}),
765-
);
766-
}
767-
});
768-
}
769-
});
826+
// Coalesce emission instead of fanning out synchronously on every
827+
// message. Live odds stream far faster than the UI needs to render.
828+
this.scheduleMarketPriceEmit();
770829

771830
// Intentionally NOT forwarding `price_change` to orderbook subscribers.
772831
// The payload only carries `best_bid` / `best_ask` (no per-level
@@ -970,6 +1029,11 @@ export class WebSocketManager {
9701029
this.cleanupMarketConnection();
9711030
this.marketReconnectAttempts = 0;
9721031
this.marketPriceCache.clear();
1032+
if (this.marketPriceEmitTimer) {
1033+
clearTimeout(this.marketPriceEmitTimer);
1034+
this.marketPriceEmitTimer = null;
1035+
}
1036+
this.marketPricePendingTokenIds.clear();
9731037
// Drop cached orderbook state so a future reconnect doesn't replay a
9741038
// stale snapshot to subscribers. The provider's REST bootstrap and the
9751039
// next live `book` event will repopulate. Also flush throttle timers so
@@ -1357,6 +1421,7 @@ export class WebSocketManager {
13571421
this.disconnectAll();
13581422
this.gameSubscriptions.clear();
13591423
this.priceSubscriptions.clear();
1424+
this.priceSubscriptionTokenSets.clear();
13601425
this.cryptoPriceSubscriptions.clear();
13611426
this.marketPriceCache.clear();
13621427
this.orderbookSubscriptions.clear();

0 commit comments

Comments
 (0)