Skip to content

perf(mobile): avoid runOnUISync in chart Gradient effect#774

Closed
AndreiCalazans wants to merge 4 commits into
coinbase:masterfrom
AndreiCalazans:perf/gradient-avoid-runonuisync
Closed

perf(mobile): avoid runOnUISync in chart Gradient effect#774
AndreiCalazans wants to merge 4 commits into
coinbase:masterfrom
AndreiCalazans:perf/gradient-avoid-runonuisync

Conversation

@AndreiCalazans

@AndreiCalazans AndreiCalazans commented Jun 25, 2026

Copy link
Copy Markdown
Member

What changed? Why?

Production Android CPU profiles of chart-heavy mobile surfaces in the Coinbase Retail app showed an anonymous function inside the chart Gradient component accounting for ~1.62s of CPU time (28%) across 65 invocations, with each slice averaging ~29ms. The flame graph stack:

commitHookEffectListMount
  └─ anonymous (Gradient.tsx — `toPositions.value` getter)
       └─ get
            └─ runOnUISync
                 └─ runOnUISync
                      └─ [HostFunction] runOnUISync

Root cause (required for bugfixes)

In Reanimated 4 (the version @coinbase/cds-mobile currently lists as a peer dep), shared-value semantics changed. Shared values now live on the UI runtime, so a JS-thread read of sharedValue.value is no longer a cheap cached local read — it is a synchronous JS↔UI round-trip via runOnUISync. That round-trip is what shows up in the trace.

The Gradient effect reads toPositions.value twice on every run:

  1. const canAnimatePositions = toPositions.value.length === targetPositions.length;
  2. fromPositions.value = [...toPositions.value]; in the can-animate branch.

But this component is the sole writer of toPositions.value — every effect run writes [...targetPositions] — so the JS thread already knows what was last written and never needs to read it back over the bridge.

Before
Note anonymous function pointing to line 206 in the Gradient.js file

Screenshot 2026-06-25 at 17 59 52

After
Note anonymous function pointing to line 206 in the Gradient.js file is fully gone in the Sanduiche bottom up view for runOnUISync

Screenshot 2026-06-25 at 17 59 41

Testing

How has it been tested?

  • Unit tests (yarn nx run mobile:test — 170 suites / 1845 tests pass)
  • Interaction tests
  • Pseudo State tests
  • Manual - Web (N/A — mobile-only file)
  • Manual - Android (production CPU trace captured on a chart-heavy Retail screen reproducing the flame-graph hotspot; verified the gradient still renders and animates identically with the patched build)
  • Manual - iOS (Emulator / Device)

Testing instructions

  1. Render a chart that uses Gradient with an animated underlying data source whose stop count is sometimes stable and sometimes changing (e.g. a line chart with a gradient stroke whose data refreshes periodically).
  2. Verify:
    • Initial gradient paints correctly on mount.
    • Gradient animates smoothly when underlying data updates with the same stop count.
    • Gradient snaps without visual glitch when the stop count changes.
    • Light/dark mode and both axis orientations (x / y) render as before.
  3. Optional perf validation: capture an Android CPU profile on a chart-heavy screen. The runOnUISync cluster under the Gradient effect that was previously present (~29ms × N invocations) should be essentially gone.

Illustrations/Icons Checklist

N/A — does not touch packages/illustrations/** or packages/icons/**.

Change management

type=routine
risk=low
impact=sev5

automerge=false

Production Android CPU profiles of chart-heavy mobile surfaces in
Coinbase Retail showed an anonymous function inside the chart
`Gradient` component accounting for ~1.62s (28%) of CPU time across
65 invocations, mean ~29ms per call. The flame graph stack:

  commitHookEffectListMount
   └─ anonymous (Gradient.tsx, 'toPositions.value' getter)
        └─ get
             └─ runOnUISync
                  └─ runOnUISync
                       └─ [HostFunction] runOnUISync

In Reanimated 4 (the version cds-mobile currently lists as a peer
dep) shared-value semantics changed: shared values live on the UI
runtime, so a JS-thread read of `sharedValue.value` is no longer
a cheap cached local read - it is a synchronous JS<->UI round-trip
via `runOnUISync`. That round-trip is what shows up in the trace.

The Gradient effect reads `toPositions.value` twice on every run:

  1. `toPositions.value.length === targetPositions.length`
  2. `fromPositions.value = [...toPositions.value]` in the
     can-animate branch

But this component is the *sole writer* of `toPositions.value` -
every effect run writes `[...targetPositions]` - so the JS thread
already knows what was last written and never needs to read it back
over the bridge.

Fix: mirror the array we last wrote into a JS-side useRef
(`lastWrittenPositionsRef`) and read from the ref in both places.

No behavior change:
  - The effect still detects stop-count changes.
  - It still animates when the count is stable.
  - It still snaps when the count changes.

Validated locally with:
  yarn nx run mobile:typecheck   # green
  yarn nx run mobile:lint        # green
  yarn nx run mobile:test        # 170 suites / 1845 tests pass
@cb-heimdall

cb-heimdall commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

✅ Heimdall Review Status

Requirement Status More Info
Reviews 1/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 1
Global minimum 0
Max 1
1
1 if commit is unverified 1
Sum 2
CODEOWNERS ✅ See below

CODEOWNERS

Code Owner Status Calculation
ui-systems-eng-team 1/1
Denominator calculation
Additional CODEOWNERS Requirement
Show calculation
Sum 0
0
From CODEOWNERS 1
Sum 1

…cial bumps)

Generated via:
  yarn bump-version mobile --bump patch --message '...' --pr 774
  yarn release  # keeps web/common/mcp-server in sync per CONTRIBUTING.md

Also retargets the changelog PR link from the fork to coinbase#774.
…cial bumps)

Re-generated after merging master (9.5.0) via:
  yarn bump-version mobile --bump patch --message '...' --pr 774
  yarn release  # keeps web/common/mcp-server in sync per CONTRIBUTING.md

Also retargets the changelog PR link from the fork to coinbase#774.
@AndreiCalazans AndreiCalazans marked this pull request as ready for review June 25, 2026 22:32
@AndreiCalazans

Copy link
Copy Markdown
Member Author

Closing in favor of a single combined PR with both chart perf changes (#770 dotted-area memo + #774 Gradient runOnUISync). New PR forthcoming with one version bump and consolidated changelog.

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

Development

Successfully merging this pull request may close these issues.

3 participants