perf: reduce Windows desktop tab switch jank#12439
Draft
huhuanming wants to merge 1 commit into
Draft
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Scope and outcome
This PR addresses the 1–2 second perceived jank when switching Wallet, Market, Swap, Perps, Earn, Refer Friends, and Discovery in the Windows Electron release build.
The optimization deliberately keeps the existing desktop navigation model and the current user profile:
The central design change is to treat route focus as a control signal for asynchronous work, rather than as rendered component state that every data-hook consumer must subscribe to.
1. Measurement design, baseline, and root-cause analysis
Benchmark design
The investigation began with a reproducible release-build baseline rather than development-mode timings.
The renderer, main process, and Windows Electron package were built with:
The packaged application was then launched with CDP enabled, without clearing the existing profile:
Each benchmark run used the following sequence:
The added development/scripts/win-tab-switch-perf.mjs script makes this procedure repeatable. It records cold and warm traces and CPU profiles, checks page-specific ready selectors, measures the first usable paint after a switch, counts long tasks and requests, and sanitizes CDP target metadata so benchmark artifacts do not capture page content or wallet data.
Evidence chain
The traces showed that the delay was primarily renderer main-thread work, not a slow route transition or a single blocking network request.
A sidebar click entered React Navigation and then spent a large synchronous block in React scheduling, including processRootScheduleInMicrotask. Focus propagation caused dozens of mounted usePromiseResult consumers to update together. Those component renders fanned out into Tamagui style calculation before Chromium could produce the next frame.
The Market trace exposed a second multiplier: closed Popover trees were still performing placement and geometry reads. Calls such as getPlacement and getBoundingClientRect forced style and layout work even though the popovers were not visible.
The warm-pass and idle evidence also mattered to the choice of fix:
The chosen direction was therefore to reduce synchronous render fan-out and no-op state writes while retaining the current navigation and data lifecycle.
2. Non-rendering route focus for usePromiseResult
Previous behavior
usePromiseResult used the rendered useRouteIsFocused/useIsFocused value. A route-focus change was therefore a React state change for every component using the hook, even when focus only controlled whether an async task was allowed to run.
On a desktop tab switch, many mounted hooks observed the same focus edge at once. Their consumers re-rendered synchronously, and the resulting Tamagui work remained inside the click-to-frame critical path.
New behavior
This PR adds useRouteIsFocusedRef and moves usePromiseResult focus handling onto that non-rendering path.
The focus ref evaluates the same conditions as the rendered focus hook:
Router events update the ref and invoke a callback only when the effective focus value changes. They do not turn the route focus edge into local rendered state for every usePromiseResult consumer.
usePromiseResult then uses that ref to control its runner. A tab focus change can update async-control state without forcing the component that owns the hook to render solely because focus changed.
Preserved semantics
This is intentionally not a removal of focus handling. The following behavior remains in place:
A focused Jest case verifies the key performance invariant directly: changing route focus from true to false and back does not increase the consumer render count.
The important distinction is that data freshness is retained, but focus is no longer broadcast as a page-wide render trigger.
3. Edge-deduplicated tab-focus notifications
useListenTabFocusState is used by mounted page trees for work that genuinely must react to tab visibility. During navigation and modal transitions it could receive repeated notifications carrying the same effective values, especially repeated inactive notifications for already inactive routes.
Those callbacks were individually small, but multiplied across mounted tabs they caused avoidable work in the same switch window.
The listener now tracks the last effective focus/modal state and invokes consumers only when that state crosses an edge:
Repeated notifications with an unchanged focus/modal state are ignored.
This does not debounce or delay real transitions, and it does not remove callbacks that pages rely on. It only removes duplicate delivery of the same state.
4. Defer closed Windows Popover geometry work
Problem
Mounted closed popovers participated in render-time placement calculation on Windows. Even though their content was not visible, the code could still execute placement, max-scroll-height calculation, and bounding-box reads.
A geometry read after style changes can force Chromium to synchronously recalculate style and layout. In the Market baseline this became a large part of the click block because many components were rerendering at once.
Change
On desktop Windows, closed popovers now skip placement and maximum-height geometry measurement until they are actually opened.
When a popover opens, the normal placement path still runs, so positioning behavior is not replaced with a cached or guessed value. The optimization removes only work that cannot affect a closed popover's visible output.
Measured and functional result
Market RecalcStyleDuration fell from 427.1 ms to 7.7 ms in the comparable formal warm trace, a reduction of about 98%.
A wallet Popover was also opened and dismissed through CDP after the change to verify that deferring closed-state measurement did not break the real open path.
5. Avoid no-op Market watchlist atom updates
The Market refresh path could receive a watchlist payload that was structurally identical to the value already stored in Jotai. It still created a new wrapper and wrote the atom.
That write changed identity and notified subscribers even though no user-visible data had changed. On a mounted Market tree, the resulting subscriber work added to the cost of returning to or leaving the page.
flushWatchListV2Atom now compares the incoming payload with the current watchlist data:
The refresh request itself is not removed. The server remains authoritative and changed watchlist data still propagates normally. This optimization only turns an identical response into a true no-op.
6. Earn-specific render and refresh stabilization
Earn had additional page-local fan-out after the shared focus problem was reduced, so it required a second layer of optimization.
Remove desktop page-wide focus state toggles
BasicEarnHome previously copied top-level focus into React state through isEarnTabFocused and isEarnDataActive. Every desktop focus change could therefore rerender the whole Earn page independently of the shared data-hook renders.
On desktop:
The native/mobile state-driven behavior is left intact.
Memoize the mounted Earn tab tree
After the first focus mounts the Earn tab content, the mounted inner tree now goes through a memoized component boundary. An unrelated parent update can bail out when its props are unchanged instead of walking the complete Earn tab tree again.
This keeps the existing mounted-tab behavior. It does not unmount Earn on blur or discard the user's warm page state.
Move inner default-tab synchronization off rendered focus
EarnMainTabs previously subscribed to rendered route focus so it could restore the correct inner default tab. That meant the synchronization mechanism itself caused another render.
It now listens through the non-rendering route-focus ref and performs the same tab synchronization on a real focus edge. The route focus remains an event for the imperative tab controller, not a reason to render the tab tree.
Keep FAQ refresh fresh without loading-state churn
A focus refresh previously reused the normal promise runner, which could reset existing FAQ content into a loading path even when the page already had valid content.
The explicit FAQ refresh now:
The page still refreshes on the intended focus edge; it simply avoids blank/loading churn and identical state writes.
Avoid identical Earn asset atom and DB writes
Earn available-assets refreshes could also produce an array identical to the current value. That previously rewrote the Earn atom and synchronized the same data to the database.
The Earn action now compares by asset type before writing:
Together, these changes reduce Earn's focus-time render and write amplification without weakening freshness or changing the native lifecycle.
7. Results, interpretation, and validation
Formal warm CDP trace
The table below reports the synchronous click block from comparable warm traced runs. These numbers include tracing overhead, so they are used primarily for before/after attribution rather than as the only user-perceived latency metric.
Across all seven tabs:
Perp is intentionally shown as a regression in the formal trace: 232.0 ms to 318.9 ms. The result does not support claiming that every individual formal trace improved. Perp includes the mounted TradingView webview and has more run-to-run scheduling variance; the actual TradingView webview was verified to be present, but this PR does not assign the regression to it without stronger evidence. The after value remains below the previous 1–2 second perceived-stall range, and the trace-free measurements below are reported separately rather than hiding this result.
Trace-free stable warm paint
Repeated warm switching without DevTools tracing produced the following click-to-paint ranges:
These values measure the packaged application in the preserved wallet profile and are closer to the user-visible result than the instrumented trace alone. Cold-pass timings remain dependent on first-time page initialization, API responses, and embedded content; they were measured separately and were not mixed into the warm-switch claim.
Settled idle probe
After allowing the application to settle for 20 seconds, a 10-second observation window recorded:
This supports the conclusion that the remaining warm-tab cost is switch-time renderer work rather than continuous background loading.
Functional verification
The CDP validation covered:
The existing Electron profile was never cleared, so the validation did not fall back to the new-wallet onboarding state.
Build and automated checks
All three requested release-build steps completed successfully:
Additional checks:
Full tsgo --noEmit still reports seven pre-existing errors outside this diff:
Those errors were present outside the changed surface and are recorded here so the validation boundary is explicit.
Why this approach
The optimization is deliberately centered on the highest-fan-out synchronous work:
This avoids improving a synthetic empty profile while regressing the real funded-wallet workflow, and it avoids exchanging warm switch jank for repeated cold-page initialization.