Skip to content

perf(mobile): chart rendering — getDottedAreaPath memo + Gradient runOnUISync#776

Open
AndreiCalazans wants to merge 4 commits into
coinbase:masterfrom
AndreiCalazans:perf/mobile-chart-rendering
Open

perf(mobile): chart rendering — getDottedAreaPath memo + Gradient runOnUISync#776
AndreiCalazans wants to merge 4 commits into
coinbase:masterfrom
AndreiCalazans:perf/mobile-chart-rendering

Conversation

@AndreiCalazans

Copy link
Copy Markdown
Member

What changed? Why?

Two unrelated but related-in-spirit mobile chart perf wins on chart-heavy surfaces of the Coinbase Retail app. Previously split across #770 and #774; consolidated here so we only burn a single cds-mobile patch bump.

1. getDottedAreaPath — array-join + LRU-1 memo (was #770)

Rewrites getDottedAreaPath to build its SVG path via Array#join and adds an LRU-1 memo keyed by bounds + sizes. In profiling of a chart-heavy mobile screen this dropped the function from ~21s / 1005 calls → ~10ms / 1 call and removed ~20s of attributable stringPrototypeConcat + GC Young Gen.

BEFORE
dotted-area-before

AFTER
dotted-area-after

2. Gradient — drop runOnUISync shared-value reads (was #774)

Production Android CPU profiles showed an anonymous function inside the chart Gradient component accounting for ~1.62s of CPU time (28%) across 65 invocations, ~29ms per slice. The flame graph stack:

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

In Reanimated 4 (the version @coinbase/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. 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. Fix mirrors the last-written array into a JS-side useRef and reads from there.

Before — anonymous function at line 206 in Gradient.js:
Screenshot 2026-06-25 at 17 59 52

After — that frame is gone from the runOnUISync bottom-up view:
Screenshot 2026-06-25 at 17 59 41

Root cause (required for bugfixes)

n/a — perf only, no behavior change in either commit.

UI changes

No visual changes. getDottedAreaPath emits the same SVG path string. Gradient still detects stop-count changes, still animates when the count is stable, still snaps when the count changes.

Testing

How has it been tested?

  • Unit tests (new tests for getDottedAreaPath covering dot-count behavior, the no-out-of-bounds invariant, and memo semantics; full mobile suite — 170 suites / 1845 tests — green for the Gradient change)
  • Interaction tests
  • Pseudo State tests
  • Manual - Web (N/A — mobile-only files)
  • Manual - Android (production CPU trace captured on a chart-heavy Retail screen reproducing both flame-graph hotspots; verified rendering is identical with the patched build)
  • Manual - iOS (Emulator / Device)

Testing instructions

  • yarn nx run mobile:test --testPathPattern=path.test covers the dotted-area changes (6 new cases).
  • For the Gradient change, 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), and 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.
  • Optional perf validation: capture an Android CPU profile on a chart-heavy screen — both the getDottedAreaPath string-concat cluster and the runOnUISync cluster under the Gradient effect 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

`getDottedAreaPath` allocates a per-dot SVG subpath via `path += ...`,
producing thousands of intermediate immutable strings on each call. In
profiling of a chart-heavy mobile surface this dominated
`stringPrototypeConcat` and young-gen GC.

Two changes:
1. Build the path via an `Array` of segments + one `.join('')` at the end
   (single allocation per call instead of thousands), and clamp dotsX/dotsY
   so the loop math is in-bounds by construction (drops the per-iteration
   bounds check).
2. Add an LRU-1 module-scope memo keyed by the bounds + sizes. The
   `drawingArea` reference passed by `CartesianChart` churns each render
   even when bounds are equal by value, so a same-string-instance cache
   lets downstream `<Path d={dottedPath}/>` consumers short-circuit too.

On the affected screen this drops `getDottedAreaPath` total time from
~21s/1005 calls to ~10ms/1 call and removes ~20s of attributable
`stringPrototypeConcat` + `GC Young Gen`. New unit tests cover the
existing dot-count behavior, the no-out-of-bounds invariant, and the
memo semantics.
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 26, 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 0
Sum 1
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)

Combined release for the two perf changes consolidated into coinbase#776:
  - perf(mobile): memoize getDottedAreaPath and build via array join
  - perf(mobile): avoid runOnUISync in chart Gradient effect

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

PR links retargeted from the fork to coinbase#776.
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