Skip to content

Reduce UI lag: gate redundant Monero callbacks#102

Open
paullinator wants to merge 3 commits intomasterfrom
paul/fixUiLag
Open

Reduce UI lag: gate redundant Monero callbacks#102
paullinator wants to merge 3 commits intomasterfrom
paul/fixUiLag

Conversation

@paullinator
Copy link
Copy Markdown
Member

@paullinator paullinator commented Feb 25, 2026

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

none

Description

Reduces RN JS thread starvation from Monero engine callbacks. Three fixes:

  • Remove stale onSubscribeAddresses from test mock: Fixes a pre-existing TypeScript error in test/engine/engine.ts where the mock EdgeCurrencyEngineCallbacks still included a property removed from the type.
  • Gate empty transaction callbacks: onTransactions is now only called when transactionEventArray is non-empty, preventing no-op bridge crossings on quiet blocks.
  • Gate onSeenTxCheckpoint + throttle onAddressesChecked: onSeenTxCheckpoint is only emitted when the checkpoint value actually advances. onAddressesChecked is globally rate-limited to 1 emission per 500ms (sync-complete always passes).

Note

Low Risk
Behavior changes are limited to callback frequency/conditions (no transaction creation/broadcast logic changes), but could affect UI sync progress and downstream listeners that implicitly relied on extra emissions.

Overview
Reduces React Native bridge/JS-thread pressure from Monero sync by gating redundant engine callbacks.

MoneroEngine now throttles onAddressesChecked progress emissions to at most once every 500ms (while still always emitting the final 1), only calls onTransactions when there are actual transaction events to deliver, and only emits onSeenTxCheckpoint when the checkpoint value advances. Tests update the EdgeCurrencyEngineCallbacks mock by removing the stale onSubscribeAddresses callback to match the current type.

Written by Cursor Bugbot for commit f6ffd9a. This will update automatically on new commits. Configure here.

The onSubscribeAddresses property was removed from
EdgeCurrencyEngineCallbacks but remained in the test mock, causing a
TypeScript compilation error.
MoneroEngine called onTransactions unconditionally, generating ~70 Redux
dispatches and bridge messages per 10 seconds for nothing. Now gates
the call on a non-empty array.
Monero fired onSeenTxCheckpoint 5-6 times per 10 seconds with identical
checkpoint values. Comparing the new checkpoint to the previous before
calling eliminates redundant emissions.

Also throttles onAddressesChecked to a maximum of one emission per 500ms
(ratio=1 always passes through) to cap aggregate sync-status volume.
Copy link
Copy Markdown

@eddy-edge eddy-edge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Findings

  • warning test/engine/engine.ts:51: No tests were added for the three new behavioral changes: (1) 500ms throttle on onAddressesChecked, (2) empty-array guard before calling onTransactions, and (3) onSeenTxCheckpoint gating on checkpoint advancement. The existing tests are almost entirely skipped integration tests that don't exercise these code paths.
    • Add unit tests for updateOnAddressesChecked and processMoneroTransaction. For the throttle, mock Date.now to verify calls within 500ms are suppressed. For the checkpoint gate, verify onSeenTxCheckpoint is only called when the checkpoint changes.

const PRIMARY_CURRENCY_TOKEN_ID = null

// Global throttle: max 1 onAddressesChecked per 500ms; ratio=1 always passes.
let lastSyncEmitTime = 0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: lastSyncEmitTime is a module-level global shared across all MoneroEngine instances. If a user has multiple Monero wallets syncing concurrently, one wallet's throttle will suppress progress callbacks for another wallet, causing its sync progress bar to appear stuck.

Recommendation: Move lastSyncEmitTime to an instance property on MoneroEngine (e.g. private lastSyncEmitTime = 0 in the class body) so each wallet throttles independently.

Comment on lines 174 to +177
if (numTx !== totalTxs) {
const now = Date.now()
if (now - lastSyncEmitTime < 500) return
lastSyncEmitTime = now
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: The throttle drops progress callbacks entirely with no trailing-edge emit. The UI may jump from e.g. 30% directly to 100% if the last sub-100% update is suppressed. For wallets with many transactions where progress updates happen rapidly, intermediate progress values are silently discarded.

Recommendation: Consider a trailing-edge throttle (schedule a deferred emit when one is suppressed) so the most recent progress value is always delivered before the final 100% callback.

}
if (numTx !== totalTxs) {
const now = Date.now()
if (now - lastSyncEmitTime < 500) return
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: The 500ms throttle interval is a magic number. It's not discoverable at the top of the file alongside the other interval constants (SYNC_INTERVAL_MILLISECONDS, SAVE_DATASTORE_MILLISECONDS).

Recommendation: Extract to a named constant: const SYNC_PROGRESS_THROTTLE_MS = 500 next to the other interval constants at the top of the file.

Comment on lines 312 to +316
this.saveTransactionState(PRIMARY_CURRENCY_TOKEN_ID, edgeTransaction)
this.edgeTxLibCallbacks.onTransactions(this.transactionEventArray)
this.transactionEventArray = []
if (this.transactionEventArray.length > 0) {
this.edgeTxLibCallbacks.onTransactions(this.transactionEventArray)
this.transactionEventArray = []
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: processMoneroTransaction fires onTransactions after every single transaction that produces events. The empty-array guard helps avoid no-op calls, but still fires one callback per new/updated transaction rather than batching. Since this PR aims to reduce UI lag, batching would be more impactful.

Recommendation: Consider moving the onTransactions flush out of processMoneroTransaction and into checkTransactionsInnerLoop after the for-loop (or every N iterations), to batch transaction events and reduce callback frequency.

Comment on lines +355 to +356
const newCheckpoint = wasSeenTxCheckpoint(seenTxCheckpoint)
const oldCheckpoint = wasSeenTxCheckpoint(this.seenTxCheckpoint)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The checkpoint comparison serializes both values via wasSeenTxCheckpoint (number→string) before comparing. Comparing the raw numeric values (seenTxCheckpoint !== this.seenTxCheckpoint) would be clearer and avoids unnecessary serialization.

Recommendation: Compare seenTxCheckpoint !== (this.seenTxCheckpoint ?? 0) directly, then call wasSeenTxCheckpoint only when emitting the callback.


const PRIMARY_CURRENCY_TOKEN_ID = null

// Global throttle: max 1 onAddressesChecked per 500ms; ratio=1 always passes.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The comment 'ratio=1 always passes' uses jargon that doesn't map to the code's variable names (numTx/totalTxs). It takes a moment to understand this means the final 100% callback bypasses the throttle.

Recommendation: Reword to: // Global throttle: max 1 onAddressesChecked call per 500ms for in-progress syncs. The final 100% callback is never throttled.

this.edgeTxLibCallbacks.onSeenTxCheckpoint(
wasSeenTxCheckpoint(this.seenTxCheckpoint)
)
// Only update the seenTxCheckpoint if it actually advanced:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The comment says 'if it actually advanced' but the comparison checks for any change (!==), not strictly advancement (increase). If seenTxCheckpoint could decrease (e.g. on a reorg), the comment would be misleading.

Recommendation: Change to: // Only update the seenTxCheckpoint if it actually changed:

Comment on lines +470 to +471
const _bal = this.walletLocalData.totalBalances.get(tokenId) ?? '0'
this.edgeTxLibCallbacks.onTokenBalanceChanged(tokenId, _bal)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The _bal variable uses an underscore prefix, which conventionally signals an unused or private member. This is a normal local variable. Additionally, this is a cosmetic-only change unrelated to fixing UI lag.

Recommendation: Rename to balance or bal. Consider dropping this change from this PR or moving it to a separate cleanup commit.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is ON. A Cloud Agent has been kicked off to fix the reported issue.

const PRIMARY_CURRENCY_TOKEN_ID = null

// Global throttle: max 1 onAddressesChecked per 500ms; ratio=1 always passes.
let lastSyncEmitTime = 0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Global throttle variable shared across all engine instances

Medium Severity

lastSyncEmitTime is a module-level variable shared across all MoneroEngine instances. When multiple Monero wallets sync concurrently, one wallet's onAddressesChecked emission resets the shared timer, starving progress callbacks for other wallets. This needs to be an instance property on MoneroEngine rather than a module-scoped let.

Additional Locations (1)

Fix in Cursor Fix in Web

@cursor
Copy link
Copy Markdown

cursor bot commented Feb 25, 2026

Bugbot Autofix prepared fixes for 1 of the 1 bugs found in the latest run.

  • ✅ Fixed: Global throttle variable shared across all engine instances
    • Moved lastSyncEmitTime from module-level scope to an instance property on MoneroEngine, ensuring each wallet has its own independent throttle timer.

Create PR

Or push these changes by commenting:

@cursor push f5769749fb
Preview (f5769749fb)
diff --git a/src/MoneroEngine.ts b/src/MoneroEngine.ts
--- a/src/MoneroEngine.ts
+++ b/src/MoneroEngine.ts
@@ -66,9 +66,6 @@
 
 const PRIMARY_CURRENCY_TOKEN_ID = null
 
-// Global throttle: max 1 onAddressesChecked per 500ms; ratio=1 always passes.
-let lastSyncEmitTime = 0
-
 export class MoneroEngine implements EdgeCurrencyEngine {
   apiKey: string
   walletInfo: SafeWalletInfo
@@ -91,6 +88,7 @@
   engineFetch: EdgeFetchFunction
   seenTxCheckpoint: number | undefined
   loginPromise: Promise<void> | null = null
+  lastSyncEmitTime: number = 0
 
   constructor(
     env: EdgeCorePluginOptions,
@@ -173,8 +171,8 @@
     }
     if (numTx !== totalTxs) {
       const now = Date.now()
-      if (now - lastSyncEmitTime < 500) return
-      lastSyncEmitTime = now
+      if (now - this.lastSyncEmitTime < 500) return
+      this.lastSyncEmitTime = now
       const progress = numTx / totalTxs
       this.edgeTxLibCallbacks.onAddressesChecked(progress)
     } else {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants