Skip to content
Merged
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.13.0 ((8/11/2026, 09:52 AM PST))

This is an artificial version bump with no new change.

## 9.12.3 ((8/10/2026, 09:17 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.12.3",
"version": "9.13.0",
"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.13.0 ((8/11/2026, 09:52 AM PST))

This is an artificial version bump with no new change.

## 9.12.3 ((8/10/2026, 09:17 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.12.3",
"version": "9.13.0",
"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.13.0 (8/11/2026 PST)

#### 🚀 Updates

- Add a lightweight static render path for non-animated LineChart/CartesianChart (animate={false} uses a cheap rectangular clip instead of the anti-aliased path clip); ScrubberProvider now skips the pan gesture and animated reaction when scrubbing is disabled; add a strokeWidth prop to ReferenceLine. [[#840](https://github.com/coinbase/cds/pull/840)]

## 9.12.3 (8/10/2026 PST)

#### 🐞 Fixes
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.12.3",
"version": "9.13.0",
"description": "Coinbase Design System - Mobile",
"repository": {
"type": "git",
Expand Down
3 changes: 2 additions & 1 deletion packages/mobile/src/visualizations/chart/CartesianChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ export type CartesianChartBaseProps = Omit<BoxBaseProps, 'fontFamily'> &
*/
layout?: CartesianChartLayout;
/**
* Whether to animate the chart.
* Whether to animate the chart. When `false`, lines render via a lightweight static path
* (a cheap rectangular clip) suited to non-interactive charts like row sparklines.
* @default true
*/
animate?: boolean;
Expand Down
139 changes: 105 additions & 34 deletions packages/mobile/src/visualizations/chart/Path.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@ const AnimatedPath = memo<
},
);

export const Path = memo<PathProps>((props) => {
// Animated path: reanimated clip-reveal + shared values. Used when the chart animates (default).
const AnimatedChartPath = memo<PathProps>((props) => {
const {
animate: animateProp,
clipRect,
Expand Down Expand Up @@ -349,39 +350,7 @@ export const Path = memo<PathProps>((props) => {
return undefined;
}, [clipPathProp, animateClip, targetClipPath]);

// Convert SVG path string to SkPath for static rendering
const staticPath = useDerivedValue(() => {
const dValue = unwrapAnimatedValue(d);
if (!dValue) return Skia.Path.Make();
return Skia.Path.MakeFromSVGString(dValue) ?? Skia.Path.Make();
}, [d]);

const isFilled = fill !== undefined && fill !== 'none';
const isStroked = stroke !== undefined && stroke !== 'none';

const content = !animate ? (
<>
{isFilled && (
<SkiaPath color={fill} opacity={fillOpacity} path={staticPath} style="fill" {...pathProps}>
{children}
</SkiaPath>
)}
{isStroked && (
<SkiaPath
color={stroke}
opacity={strokeOpacity}
path={staticPath}
strokeCap={strokeCap}
strokeJoin={strokeJoin}
strokeWidth={strokeWidth}
style="stroke"
{...pathProps}
>
{children}
</SkiaPath>
)}
</>
) : (
const content = (
<AnimatedPath
d={d}
fill={fill}
Expand Down Expand Up @@ -416,3 +385,105 @@ export const Path = memo<PathProps>((props) => {
</Group>
);
});

// Non-animated path: skips the clip-reveal animation and uses a cheap rectangular clip.
const StaticChartPath = memo<
Omit<PathProps, 'animate' | 'initialPath' | 'transition' | 'transitions'>
>(
({
clipRect,
clipPath: clipPathProp,
clipOffset = 0,
d = '',
fill,
fillOpacity,
stroke,
strokeOpacity,
strokeWidth,
strokeCap,
strokeJoin,
children,
...pathProps
}) => {
const context = useCartesianChartContext();
const rect = clipRect ?? context.drawingArea;

// Derived (not memoized) so an animated `d` — e.g. a reference line — still tracks scrubbing.
const path = useDerivedValue(() => {
const dValue = unwrapAnimatedValue(d);
if (!dValue) return Skia.Path.Make();
return Skia.Path.MakeFromSVGString(dValue) ?? Skia.Path.Make();
}, [d]);
Comment thread
alfonsocj marked this conversation as resolved.

// Rect clip routes to canvas.clipRect (a cheap GPU scissor), not the anti-aliased path clip.
const clip = useMemo(() => {
if (clipPathProp !== undefined) return clipPathProp;
if (!rect) return null;
return {
x: rect.x - clipOffset,
y: rect.y - clipOffset,
width: rect.width + clipOffset * 2,
height: rect.height + clipOffset * 2,
};
}, [clipPathProp, rect, clipOffset]);

const isFilled = fill !== undefined && fill !== 'none';
const isStroked = stroke !== undefined && stroke !== 'none';

const content = (
<>
{isFilled && (
<SkiaPath color={fill} opacity={fillOpacity} path={path} style="fill" {...pathProps}>
{children}
</SkiaPath>
)}
{isStroked && (
<SkiaPath
color={stroke}
opacity={strokeOpacity}
path={path}
strokeCap={strokeCap}
strokeJoin={strokeJoin}
strokeWidth={strokeWidth}
style="stroke"
{...pathProps}
>
{children}
</SkiaPath>
)}
</>
);

if (clip === null) {
return <Group>{content}</Group>;
}

return <Group clip={clip}>{content}</Group>;
},
);

/**
* Renders a chart path. Delegates to a lightweight static renderer when the chart is not
* animating (no reanimated hooks, cheap rect clip) and to the animated renderer otherwise.
*/
export const Path = memo<PathProps>(
({ animate: animateProp, initialPath, transition, transitions, ...staticProps }) => {
const context = useCartesianChartContext();
const animate = animateProp ?? context.animate;

if (animate) {
return (
<AnimatedChartPath
animate={animateProp}
initialPath={initialPath}
transition={transition}
transitions={transitions}
{...staticProps}
/>
);
}

// Animation-only props are omitted from the static renderer.
return <StaticChartPath {...staticProps} />;
},
);
80 changes: 80 additions & 0 deletions packages/mobile/src/visualizations/chart/__tests__/Path.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { render, screen } from '@testing-library/react-native';

import { useCartesianChartContext } from '../ChartProvider';
import { Path } from '../Path';

type MockSkPath = { type: string; addRect: jest.Mock };

const makePath = (): MockSkPath => ({ type: 'SkPath', addRect: jest.fn() });

jest.mock('@shopify/react-native-skia', () => {
const React = require('react');
const { View } = require('react-native');
return {
// Surface the `clip` prop so tests can distinguish a rect clip from an SkPath clip.
Group: ({ children, clip }: { children?: React.ReactNode; clip?: unknown }) =>
React.createElement(View, { testID: 'group', clip }, children),
Path: ({ style }: { style?: string }) => React.createElement(View, { testID: `path-${style}` }),
Skia: {
Path: {
Make: jest.fn(makePath),
MakeFromSVGString: jest.fn((str: string) => ({ ...makePath(), svgString: str })),
},
},
usePathInterpolation: jest.fn(() => makePath()),
};
});

jest.mock('react-native-reanimated', () => ({
...jest.requireActual('react-native-reanimated/mock'),
isSharedValue: jest.fn(() => false),
useSharedValue: jest.fn((v: unknown) => ({ value: v })),
useDerivedValue: jest.fn((fn: () => unknown) => ({ value: fn() })),
}));

jest.mock('../ChartProvider', () => ({ useCartesianChartContext: jest.fn() }));

const mockedUseContext = useCartesianChartContext as unknown as jest.Mock;

const drawingArea = { x: 0, y: 0, width: 100, height: 40 };

function mockContext(animate: boolean) {
mockedUseContext.mockReturnValue({
animate,
layout: 'vertical',
drawingArea,
getXScale: () => (value: number) => value,
});
}

describe('Path interactive/static rendering', () => {
afterEach(() => jest.clearAllMocks());

it('uses a cheap rectangular clip (not a path clip) when the chart is not animating', () => {
mockContext(false);
render(<Path d="M0 0 L10 10" stroke="red" strokeWidth={2} />);

const clip = screen.getByTestId('group').props.clip;
// A rect clip is a plain object with numeric bounds; it routes to canvas.clipRect.
expect(typeof clip.width).toBe('number');
expect(typeof clip.height).toBe('number');
expect(clip.type).toBeUndefined();
});

it('honors an explicit clipPath in static mode', () => {
mockContext(false);
render(<Path clipPath={null} d="M0 0 L10 10" stroke="red" strokeWidth={2} />);

// clipPath={null} disables clipping entirely.
expect(screen.getByTestId('group').props.clip).toBeUndefined();
});

it('renders the animated path (SkPath clip) when the chart is animating', () => {
mockContext(true);
render(<Path d="M0 0 L10 10" stroke="red" strokeWidth={2} />);

const clip = screen.getByTestId('group').props.clip;
// The animated renderer clips with an SkPath, not a plain rect.
expect(clip?.type ?? clip?.svgString !== undefined).toBeTruthy();
});
});
4 changes: 4 additions & 0 deletions packages/mobile/src/visualizations/chart/line/LineChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ export type LineChartProps = LineChartBaseProps &
scrubberAccessibilityLabelStep?: number;
};

/**
* A line chart built on `CartesianChart`. For non-interactive usage (e.g. row sparklines), pass
* `animate={false}` for a lightweight static render (cheap rectangular clip, no entrance animation).
*/
export const LineChart = memo(
({
ref,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ export type ReferenceLineBaseProps = {
* @default theme.color.bgLine
*/
stroke?: string;
/**
* Width of the line.
* @default the line component's default (2)
*/
strokeWidth?: number;
/**
* Opacity applied to both the line and label.
* @default 1
Expand Down Expand Up @@ -169,6 +174,7 @@ export const ReferenceLine = memo<ReferenceLineProps>(
labelVerticalAlignment,
labelBoundsInset,
stroke,
strokeWidth,
opacity = 1,
}) => {
const theme = useTheme();
Expand Down Expand Up @@ -235,6 +241,7 @@ export const ReferenceLine = memo<ReferenceLineProps>(
d={horizontalLine}
stroke={effectiveLineStroke}
strokeOpacity={opacity}
strokeWidth={strokeWidth}
/>
{label && (
<LabelComponent
Expand Down Expand Up @@ -274,6 +281,7 @@ export const ReferenceLine = memo<ReferenceLineProps>(
d={verticalLine}
stroke={effectiveLineStroke}
strokeOpacity={opacity}
strokeWidth={strokeWidth}
/>
{label && (
<LabelComponent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1999,6 +1999,25 @@ function ExampleNavigator() {
/>
),
},
{
title: 'Lightweight (Static)',
component: (
<LineChart
animate={false}
height={200}
series={[
{
id: 'prices',
data: [-20, 12, -8, 34, -15, 28, -4, 40, -25, 18, -10, 30, -18, 22],
},
]}
yAxis={{ domain: { min: -40, max: 45 } }}
>
{/* Curve weaves through the reference line (negative + positive values). */}
<ReferenceLine LineComponent={DottedLine} dataY={0} strokeWidth={2} />
</LineChart>
),
},
{
title: 'Horizontal Layout',
component: <HorizontalLayoutLineChart />,
Expand Down
Loading
Loading