perf(mobile): chart rendering — getDottedAreaPath memo + Gradient runOnUISync#776
Open
AndreiCalazans wants to merge 4 commits into
Open
perf(mobile): chart rendering — getDottedAreaPath memo + Gradient runOnUISync#776AndreiCalazans wants to merge 4 commits into
AndreiCalazans wants to merge 4 commits into
Conversation
`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
Collaborator
✅ Heimdall Review Status
✅
|
| Code Owner | Status | Calculation | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| ui-systems-eng-team |
✅
1/1
|
Denominator calculation
|
…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.
cb-ekuersch
approved these changes
Jun 26, 2026
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.
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-mobilepatch bump.1.
getDottedAreaPath— array-join + LRU-1 memo (was #770)Rewrites
getDottedAreaPathto build its SVG path viaArray#joinand 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 attributablestringPrototypeConcat+GC Young Gen.BEFORE

AFTER

2. Gradient — drop
runOnUISyncshared-value reads (was #774)Production Android CPU profiles showed an anonymous function inside the chart
Gradientcomponent accounting for ~1.62s of CPU time (28%) across 65 invocations, ~29ms per slice. The flame graph stack:In Reanimated 4 (the version
@coinbase/cds-mobilecurrently lists as a peer dep), shared-value semantics changed: shared values live on the UI runtime, so a JS-thread read ofsharedValue.valueis no longer a cheap cached local read — it is a synchronous JS↔UI round-trip viarunOnUISync. That round-trip is what shows up in the trace.The Gradient effect reads
toPositions.valuetwice on every run:const canAnimatePositions = toPositions.value.length === targetPositions.length;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-sideuseRefand reads from there.Before — anonymous function at line 206 in

Gradient.js:After — that frame is gone from the

runOnUISyncbottom-up view:Root cause (required for bugfixes)
n/a — perf only, no behavior change in either commit.
UI changes
No visual changes.
getDottedAreaPathemits the same SVG path string.Gradientstill detects stop-count changes, still animates when the count is stable, still snaps when the count changes.Testing
How has it been tested?
getDottedAreaPathcovering dot-count behavior, the no-out-of-bounds invariant, and memo semantics; full mobile suite — 170 suites / 1845 tests — green for the Gradient change)Testing instructions
yarn nx run mobile:test --testPathPattern=path.testcovers the dotted-area changes (6 new cases).Gradientwith 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:x/y) render as before.getDottedAreaPathstring-concat cluster and therunOnUISynccluster under the Gradient effect should be essentially gone.Illustrations/Icons Checklist
N/A — does not touch
packages/illustrations/**orpackages/icons/**.Change management
type=routine
risk=low
impact=sev5
automerge=false