Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/common/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 9.5.1 ((6/26/2026, 09:55 AM PST))

This is an artificial version bump with no new change.

## 9.5.0 ((6/25/2026, 07:24 AM PST))

This is an artificial version bump with no new change.
Expand Down
2 changes: 1 addition & 1 deletion packages/common/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-common",
"version": "9.5.0",
"version": "9.5.1",
"description": "Coinbase Design System - Common",
"repository": {
"type": "git",
Expand Down
4 changes: 4 additions & 0 deletions packages/mcp-server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 9.5.1 ((6/26/2026, 09:55 AM PST))

This is an artificial version bump with no new change.

## 9.5.0 ((6/25/2026, 07:24 AM PST))

This is an artificial version bump with no new change.
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-mcp-server",
"version": "9.5.0",
"version": "9.5.1",
"description": "Coinbase Design System - MCP Server",
"repository": {
"type": "git",
Expand Down
6 changes: 6 additions & 0 deletions packages/mobile/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 9.5.1 (6/26/2026 PST)

#### 🐞 Fixes

- Perf: chart rendering — rewrite `getDottedAreaPath` with array-join + LRU-1 memo (eliminating ~21s of string-concat + GC churn on chart-heavy screens), and drop redundant `sharedValue.value` reads in chart `Gradient` effect (removing a ~1.62s/28% CPU `runOnUISync` hotspot on production Android traces). [[#776](https://github.com/coinbase/cds/pull/776)]

## 9.5.0 (6/25/2026 PST)

#### 🚀 Updates
Expand Down
2 changes: 1 addition & 1 deletion packages/mobile/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-mobile",
"version": "9.5.0",
"version": "9.5.1",
"description": "Coinbase Design System - Mobile",
"repository": {
"type": "git",
Expand Down
18 changes: 16 additions & 2 deletions packages/mobile/src/visualizations/chart/gradient/Gradient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ export const Gradient = memo<GradientProps>(

const hasRendered = useRef(false);

// Mirror the array we last wrote into `toPositions` / `fromPositions` so
// the effect below never has to read `toPositions.value` from the JS
// thread. In Reanimated 4 each JS-side read of `sharedValue.value` is a
// synchronous `runOnUISync` round-trip (~29ms per call in production
// Android traces, ~1.6s / 28% of CPU on chart-heavy screens). This
// component is the sole writer of `toPositions.value` -- every effect
// run writes `[...targetPositions]` -- so a JS-side ref is always in
// sync with the UI runtime and lets us compare lengths and copy the
// previous array without going over the bridge.
const lastWrittenPositionsRef = useRef(targetPositions);

useEffect(() => {
if (!shouldRender) {
hasRendered.current = false;
Expand All @@ -131,6 +142,7 @@ export const Gradient = memo<GradientProps>(
fromPositions.value = [...targetPositions];
toPositions.value = [...targetPositions];
positionsProgress.value = 1;
lastWrittenPositionsRef.current = targetPositions;
return;
}

Expand All @@ -139,10 +151,11 @@ export const Gradient = memo<GradientProps>(
endX.value = buildTransition(targetEnd.x, transition);
endY.value = buildTransition(targetEnd.y, transition);

const canAnimatePositions = toPositions.value.length === targetPositions.length;
const previousPositions = lastWrittenPositionsRef.current;
const canAnimatePositions = previousPositions.length === targetPositions.length;
if (canAnimatePositions) {
currentColors.value = targetColors;
fromPositions.value = [...toPositions.value];
fromPositions.value = [...previousPositions];
toPositions.value = [...targetPositions];
positionsProgress.value = 0;
positionsProgress.value = buildTransition(1, transition);
Expand All @@ -152,6 +165,7 @@ export const Gradient = memo<GradientProps>(
toPositions.value = [...targetPositions];
positionsProgress.value = 1;
}
lastWrittenPositionsRef.current = targetPositions;
}, [
transition,
targetStart.x,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type ChartPathCurveType,
getAreaPath,
getBarPath,
getDottedAreaPath,
getLinePath,
getPathCurveFunction,
} from '../path';
Expand Down Expand Up @@ -416,3 +417,59 @@ describe('getBarPath', () => {
);
});
});

describe('getDottedAreaPath', () => {
const bounds = { x: 0, y: 0, width: 20, height: 12 };

it('returns empty string for non-positive dimensions or sizes', () => {
expect(getDottedAreaPath({ x: 0, y: 0, width: 0, height: 10 }, 4, 1)).toBe('');
expect(getDottedAreaPath({ x: 0, y: 0, width: 10, height: 0 }, 4, 1)).toBe('');
expect(getDottedAreaPath({ x: 0, y: 0, width: 10, height: 10 }, 0, 1)).toBe('');
expect(getDottedAreaPath({ x: 0, y: 0, width: 10, height: 10 }, 4, 0)).toBe('');
});

it('emits one circle subpath per dot that fits inside the bounds', () => {
// bounds 20x12, patternSize 4 -> 5x3 = 15 dots; each subpath starts with `M`.
const result = getDottedAreaPath(bounds, 4, 1);
const moveCount = (result.match(/M /g) ?? []).length;
expect(moveCount).toBe(15);
});

it('only emits dots whose center is inside the bounds', () => {
// bounds 9x4, patternSize 4 -> ceil(9/4)=3 columns but the center of
// column 2 is 10 (outside), so only 2 columns x 1 row = 2 dots.
const result = getDottedAreaPath({ x: 0, y: 0, width: 9, height: 4 }, 4, 1);
const moveCount = (result.match(/M /g) ?? []).length;
expect(moveCount).toBe(2);
});

it('honors the bounds offset', () => {
const a = getDottedAreaPath({ x: 0, y: 0, width: 8, height: 8 }, 4, 1);
const b = getDottedAreaPath({ x: 100, y: 50, width: 8, height: 8 }, 4, 1);
// Same shape, shifted: same number of dots, different coordinates.
expect((a.match(/M /g) ?? []).length).toBe((b.match(/M /g) ?? []).length);
expect(a).not.toBe(b);
expect(b).toContain('M 102,');
});

it('returns the same string instance for identical bounds (LRU-1 memo)', () => {
const first = getDottedAreaPath(bounds, 4, 1);
const second = getDottedAreaPath({ ...bounds }, 4, 1);
// Reference equality, not just structural equality: this is what lets
// downstream `<Path d={...}/>` consumers short-circuit.
expect(second).toBe(first);
});

it('returns a different result when bounds / sizes change the visual output', () => {
const base = getDottedAreaPath(bounds, 4, 1);
expect(getDottedAreaPath({ ...bounds, x: 1 }, 4, 1)).not.toBe(base);
expect(getDottedAreaPath({ ...bounds, y: 1 }, 4, 1)).not.toBe(base);
// Width/height must cross a dot threshold to change the visual output;
// sub-pattern increments correctly hit the cache and may return an equal
// string. Use full-pattern jumps here.
expect(getDottedAreaPath({ ...bounds, width: bounds.width + 4 }, 4, 1)).not.toBe(base);
expect(getDottedAreaPath({ ...bounds, height: bounds.height + 4 }, 4, 1)).not.toBe(base);
expect(getDottedAreaPath(bounds, 5, 1)).not.toBe(base);
expect(getDottedAreaPath(bounds, 4, 2)).not.toBe(base);
});
});
60 changes: 40 additions & 20 deletions packages/mobile/src/visualizations/chart/utils/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,18 @@ export const getBarPath = (
* @deprecated Prefer a shader for dotted areas instead. This will be removed in a future major release.
* @deprecationExpectedRemoval v10
*/
// Perf: LRU-1 memo + array-join build (was: `path += ...` per dot which
// allocated thousands of intermediate strings per call and dominated
// stringPrototypeConcat / young-gen GC on chart-heavy screens).
//
// On a real-world prediction-markets odds chart (~320x200, patternSize 4),
// this function was previously invoked ~1k times per scenario because the
// `drawingArea` ref churns each chart render even when bounds are equal by
// value. Returning the same string instance for identical bounds lets the
// downstream `<Path d={dottedPath}/>` short-circuit on the Skia side too
// (Skia skips re-parsing when the `d` string identity is unchanged).
let _dottedAreaPathCacheKey = '';
let _dottedAreaPathCacheValue = '';
export const getDottedAreaPath = (
bounds: { x: number; y: number; width: number; height: number },
patternSize: number,
Expand All @@ -368,31 +380,39 @@ export const getDottedAreaPath = (
return '';
}

let path = '';
const key = `${bounds.x}|${bounds.y}|${bounds.width}|${bounds.height}|${patternSize}|${dotSize}`;
if (key === _dottedAreaPathCacheKey) {
return _dottedAreaPathCacheValue;
}

// Calculate the number of dots that fit in each dimension. Clamp here so
// the inner loop doesn't need a per-iteration
// `centerX/Y <= bounds.{x+w,y+h}` check: dotsX/dotsY are computed so that
// every generated center is in-bounds by construction.
const halfPattern = patternSize / 2;
const dotsX = Math.max(0, Math.floor((bounds.width - halfPattern) / patternSize) + 1);
const dotsY = Math.max(0, Math.floor((bounds.height - halfPattern) / patternSize) + 1);

// Calculate the number of dots that fit in each dimension
const dotsX = Math.ceil(bounds.width / patternSize);
const dotsY = Math.ceil(bounds.height / patternSize);
const parts: string[] = new Array(dotsX * dotsY);
let idx = 0;
const twoDot = dotSize * 2;
const negTwoDot = -twoDot;

// Generate circles in a grid pattern
for (let row = 0; row < dotsY; row++) {
const centerY = bounds.y + row * patternSize + halfPattern;
const topY = centerY - dotSize;
for (let col = 0; col < dotsX; col++) {
const centerX = bounds.x + col * patternSize + patternSize / 2;
const centerY = bounds.y + row * patternSize + patternSize / 2;

// Only draw dots that are within the bounds
if (
centerX >= bounds.x &&
centerX <= bounds.x + bounds.width &&
centerY >= bounds.y &&
centerY <= bounds.y + bounds.height
) {
// Create circle using SVG arc commands
// M cx,cy-r a r,r 0 1,0 0,2r a r,r 0 1,0 0,-2r
path += `M ${centerX},${centerY - dotSize} a ${dotSize},${dotSize} 0 1,0 0,${dotSize * 2} a ${dotSize},${dotSize} 0 1,0 0,${-dotSize * 2} `;
}
const centerX = bounds.x + col * patternSize + halfPattern;
// Create circle using SVG arc commands:
// M cx,cy-r a r,r 0 1,0 0,2r a r,r 0 1,0 0,-2r
parts[idx++] =
`M ${centerX},${topY} a ${dotSize},${dotSize} 0 1,0 0,${twoDot} a ${dotSize},${dotSize} 0 1,0 0,${negTwoDot} `;
}
}

return path.trim();
// Single string allocation for the joined result; trim once.
const result = (parts.length === idx ? parts : parts.slice(0, idx)).join('').trim();
_dottedAreaPathCacheKey = key;
_dottedAreaPathCacheValue = result;
return result;
};
4 changes: 4 additions & 0 deletions packages/web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 9.5.1 ((6/26/2026, 09:55 AM PST))

This is an artificial version bump with no new change.

## 9.5.0 (6/25/2026 PST)

#### 🚀 Updates
Expand Down
2 changes: 1 addition & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-web",
"version": "9.5.0",
"version": "9.5.1",
"description": "Coinbase Design System - Web",
"repository": {
"type": "git",
Expand Down
Loading