diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index cc92c24462..790c828818 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/common/package.json b/packages/common/package.json index 0c76113174..d78134d2b9 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "9.5.0", + "version": "9.5.1", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 8ebb68217b..7f3a70116d 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 0552bd46f0..a32d2c087a 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -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", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index a97c185c52..c9cc5838e8 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 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 diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 5907c96681..df31b356f9 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "9.5.0", + "version": "9.5.1", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/mobile/src/visualizations/chart/gradient/Gradient.tsx b/packages/mobile/src/visualizations/chart/gradient/Gradient.tsx index 64c06f45e6..ef69005e21 100644 --- a/packages/mobile/src/visualizations/chart/gradient/Gradient.tsx +++ b/packages/mobile/src/visualizations/chart/gradient/Gradient.tsx @@ -113,6 +113,17 @@ export const Gradient = memo( 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; @@ -131,6 +142,7 @@ export const Gradient = memo( fromPositions.value = [...targetPositions]; toPositions.value = [...targetPositions]; positionsProgress.value = 1; + lastWrittenPositionsRef.current = targetPositions; return; } @@ -139,10 +151,11 @@ export const Gradient = memo( 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); @@ -152,6 +165,7 @@ export const Gradient = memo( toPositions.value = [...targetPositions]; positionsProgress.value = 1; } + lastWrittenPositionsRef.current = targetPositions; }, [ transition, targetStart.x, diff --git a/packages/mobile/src/visualizations/chart/utils/__tests__/path.test.ts b/packages/mobile/src/visualizations/chart/utils/__tests__/path.test.ts index 9bd84c4c7e..64d7f5f4b8 100644 --- a/packages/mobile/src/visualizations/chart/utils/__tests__/path.test.ts +++ b/packages/mobile/src/visualizations/chart/utils/__tests__/path.test.ts @@ -2,6 +2,7 @@ import { type ChartPathCurveType, getAreaPath, getBarPath, + getDottedAreaPath, getLinePath, getPathCurveFunction, } from '../path'; @@ -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 `` 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); + }); +}); diff --git a/packages/mobile/src/visualizations/chart/utils/path.ts b/packages/mobile/src/visualizations/chart/utils/path.ts index 2c6e5f16c1..afebc552aa 100644 --- a/packages/mobile/src/visualizations/chart/utils/path.ts +++ b/packages/mobile/src/visualizations/chart/utils/path.ts @@ -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 `` 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, @@ -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; }; diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index ea42cadff9..82ee1b7e29 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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 diff --git a/packages/web/package.json b/packages/web/package.json index 7b694548c0..6170b68729 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "9.5.0", + "version": "9.5.1", "description": "Coinbase Design System - Web", "repository": { "type": "git",