Skip to content

perf: reduce Windows desktop tab switch jank#12439

Draft
huhuanming wants to merge 1 commit into
xfrom
codex/win-tab-switch-perf
Draft

perf: reduce Windows desktop tab switch jank#12439
huhuanming wants to merge 1 commit into
xfrom
codex/win-tab-switch-perf

Conversation

@huhuanming

@huhuanming huhuanming commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

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 Windows release was built and launched locally, not through a remote environment;
  • the existing funded test wallet was preserved throughout the work;
  • application data was not cleared between runs;
  • pages remain mounted so their warm state is retained;
  • refresh, polling, lock-screen, modal, and focus-recovery behavior is preserved;
  • the solution does not assume a separate desktop bg/main runtime.

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:

yarn workspace @onekeyhq/desktop build:renderer
yarn workspace @onekeyhq/desktop build:main
yarn workspace @onekeyhq/desktop build:electron:win --publish never

The packaged application was then launched with CDP enabled, without clearing the existing profile:

powershell -ExecutionPolicy Bypass -File apps\desktop\scripts\build-launch-perf-win.ps1 -NoBuild -Detach -Port 9222

Each benchmark run used the following sequence:

  1. connect to the packaged Electron renderer through CDP;
  2. verify that the Wallet home page and existing account balance are visible;
  3. switch through Market, Swap, Perps, Earn, Refer Friends, Discovery, and Wallet once as the cold pass;
  4. allow all mounted tabs and embedded content to finish loading and settle;
  5. switch through the same tabs again as the warm pass;
  6. collect Chrome trace, CPU profile, task/script/layout/style metrics, DOM mutation timing, long tasks, requests, and tab-ready/paint timing;
  7. run a separate trace-free warm cycle, because DevTools tracing itself adds measurable overhead;
  8. after a 20-second settle, observe a 10-second idle window to distinguish tab-switch work from background traffic or polling.

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:

  • warm switches still contained the synchronous React/style block after data had loaded;
  • the settled idle probe produced no new resources and no long tasks;
  • therefore clearing caches, suppressing all refreshes, or treating the issue as a network-only problem would not address the measured bottleneck;
  • unmounting every inactive tab would reduce mounted work, but would trade the switch jank for repeated cold initialization and loss of warm page state.

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:

  • the current navigation route must be focused;
  • the root-router/modal depth must allow the page to be considered visible;
  • the lock screen must be respected unless the caller explicitly disables that check;
  • overrideIsFocused is still applied when supplied by the caller.

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:

  • requests with checkIsFocused enabled do not begin while the route is inactive;
  • the deferred promise gate is reset on blur and resolved on focus;
  • dependency changes that occur while blurred are remembered and recovered after focus;
  • revalidateOnFocus still schedules a refresh on a real false-to-true transition;
  • native revalidation is deferred through requestIdleCallback so the first frame can paint before recovery work begins;
  • revalidateOnReconnect behavior remains available;
  • polling still uses its existing nonce and focus checks;
  • lock-screen and modal visibility continue to affect the effective focus state;
  • in-flight results keep their existing result/state semantics;
  • scheduled focus-recovery idle callbacks are cancelled when the hook unmounts.

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:

  • inactive to active;
  • active to inactive;
  • visible to hidden by a modal;
  • hidden by a modal to visible.

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:

  • if the payload is identical, it returns the current value and skips the atom write;
  • if the payload changed, it performs the same update as before.

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:

  • pure focus and analytics bookkeeping now uses refs;
  • the data-active value remains stable because desktop data hooks already gate their work with the new route-focus ref;
  • real focus edges still trigger entry analytics, FAQ refresh, asset prefetch, and page refresh behavior where required.

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:

  1. requests the latest FAQ list;
  2. retains the current rendered content during the request;
  3. compares the result with the current list;
  4. reuses the current state when the response is identical;
  5. updates the state normally when the FAQ data changed.

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:

  • an identical available-assets array skips both the atom update and DB synchronization;
  • changed data follows the existing atom and persistence path.

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.

Tab Baseline After Change
Market 543.6 ms 159.0 ms -70.8%
Swap 304.5 ms 164.0 ms -46.1%
Perp 232.0 ms 318.9 ms +37.5%
Earn 434.9 ms 386.9 ms -11.0%
Refer Friends 127.7 ms 84.3 ms -34.0%
Discovery 123.4 ms 90.0 ms -27.1%
Wallet 166.3 ms 134.9 ms -18.9%

Across all seven tabs:

  • mean synchronous click blocking fell from approximately 276 ms to 191 ms, about a 31% reduction;
  • the worst measured tab fell from 543.6 ms to 386.9 ms, about a 29% reduction;
  • Market style recalculation fell from 427.1 ms to 7.7 ms, about a 98% reduction.

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:

Tab Stable warm paint
Market 0.52–0.81 s
Swap 0.30–0.57 s
Perp 0.59–0.62 s
Earn 0.47–0.52 s
Refer Friends approximately 0.21 s
Discovery 0.28–0.30 s
Wallet 0.42–0.46 s

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:

  • 0 long tasks;
  • 0 new resources.

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:

  • Wallet;
  • Market;
  • Swap;
  • Perps, including the real TradingView webview target;
  • Earn;
  • Refer Friends;
  • Discovery;
  • the existing funded test wallet and visible balance;
  • opening and dismissing a wallet Popover;
  • a complete cold pass followed by a settled warm pass;
  • repeated trace-free warm switching.

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:

yarn workspace @onekeyhq/desktop build:renderer
yarn workspace @onekeyhq/desktop build:main
yarn workspace @onekeyhq/desktop build:electron:win --publish never

Additional checks:

  • 34 focused Jest tests passed;
  • Prettier passed for the changed files;
  • type-aware Oxlint passed for the changed files;
  • the benchmark script syntax check passed.

Full tsgo --noEmit still reports seven pre-existing errors outside this diff:

  • six missing exports from react-native-collapsible-tab-view;
  • HardwareErrorCode.PinMismatch.

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:

  1. measure a real packaged Windows build with the user's persistent profile;
  2. separate cold initialization, warm switching, tracing overhead, and settled idle behavior;
  3. use traces and CPU/style metrics to locate the click-to-frame critical path;
  4. remove shared render amplification before adding page-specific workarounds;
  5. preserve refresh and navigation semantics, then prove the non-rendering invariant with focused tests;
  6. apply page-local equality and memoization only where traces still showed avoidable work;
  7. rebuild the release and validate through the same CDP path used for the baseline.

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.

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.

1 participant