From b2c6240c0a33a1a0f3f8c5ffb4bae81087c9a1d9 Mon Sep 17 00:00:00 2001 From: Adrien Zheng Date: Wed, 26 Aug 2026 12:04:08 -0400 Subject: [PATCH 1/3] feat(mobile): let TextIcon respect IconGlyphSourceContext overrides TextIcon hardcoded 'CoinbaseIcons' and read directly from the CDS glyphMap, bypassing the IconGlyphSourceContext used by Icon. This meant retail-icons (and any other IconGlyphSourceProvider) had no effect on TextIcon glyphs. - Export IconGlyphSourceContext from createIcon so TextIcon can access it - Resolve context source first (same priority logic as createIcon/resolveGlyph), falling back to the CDS glyphMap when the context source doesn't cover the name - Apply the resolved fontFamily instead of hardcoding 'CoinbaseIcons' - Add TextIcon.test.tsx with 6 cases covering the override and fallback paths Co-authored-by: Cursor --- packages/mobile/src/icons/TextIcon.tsx | 36 +++++-- .../src/icons/__tests__/TextIcon.test.tsx | 97 +++++++++++++++++++ packages/mobile/src/icons/createIcon.tsx | 2 +- 3 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 packages/mobile/src/icons/__tests__/TextIcon.test.tsx diff --git a/packages/mobile/src/icons/TextIcon.tsx b/packages/mobile/src/icons/TextIcon.tsx index 107b671db3..ebbbd012e9 100644 --- a/packages/mobile/src/icons/TextIcon.tsx +++ b/packages/mobile/src/icons/TextIcon.tsx @@ -1,4 +1,4 @@ -import React, { memo, useMemo } from 'react'; +import React, { memo, useContext, useMemo } from 'react'; import { Animated, Text } from 'react-native'; import type { StyleProp, TextStyle } from 'react-native'; import type { IconName } from '@coinbase/cds-common/types/IconName'; @@ -8,7 +8,7 @@ import { isDevelopment } from '@coinbase/cds-utils'; import { useTheme } from '../hooks/useTheme'; import type { IconProps } from './Icon'; -import { getIconSourceSize } from './Icon'; +import { DEFAULT_ICON_FONT_FAMILY, getIconSourceSize, IconGlyphSourceContext } from './createIcon'; export type TextIconProps = Pick & { name: IconName; @@ -44,26 +44,46 @@ export const TextIcon = memo(function TextIcon({ const sourceSize = getIconSourceSize(iconSize); const iconColor = theme.color[color]; + const contextSource = useContext(IconGlyphSourceContext); + + const iconKey = `${name}-${sourceSize}-${active ? 'active' : 'inactive'}`; + + // Context source (e.g. retail-icons override) takes priority over the CDS glyphMap. + const contextGlyph = contextSource + ? contextSource.getGlyph + ? contextSource.getGlyph({ + glyphMap: contextSource.glyphMap, + name, + size, + pixelSize: iconSize, + active: Boolean(active), + }) + : contextSource.glyphMap[iconKey as keyof typeof contextSource.glyphMap] + : undefined; + + const glyph = contextGlyph ?? glyphMap[iconKey as keyof typeof glyphMap]; + const fontFamily = + contextGlyph !== undefined + ? (contextSource?.fontFamily ?? DEFAULT_ICON_FONT_FAMILY) + : DEFAULT_ICON_FONT_FAMILY; + const styles = useMemo( () => [ { - fontFamily: 'CoinbaseIcons', + fontFamily, fontSize: iconSize, color: iconColor, }, style, // TODO https://linear.app/coinbase/issue/CDS-1518/audit-potentially-harmful-reactnative-animated-pattern ] as StyleProp, - [style, iconColor, iconSize], + [style, iconColor, iconSize, fontFamily], ); - const iconName = `${name}-${sourceSize}-${active ? 'active' : 'inactive'}`; - const glyph = glyphMap[iconName as keyof typeof glyphMap]; - if (glyph === undefined) { if (isDevelopment()) { - console.error(`Unable to find glyph for icon name "${name}" with glyph key "${iconName}"`); + console.error(`Unable to find glyph for icon name "${name}" with glyph key "${iconKey}"`); } return null; } diff --git a/packages/mobile/src/icons/__tests__/TextIcon.test.tsx b/packages/mobile/src/icons/__tests__/TextIcon.test.tsx new file mode 100644 index 0000000000..c1d88afcec --- /dev/null +++ b/packages/mobile/src/icons/__tests__/TextIcon.test.tsx @@ -0,0 +1,97 @@ +import type { IconName } from '@coinbase/cds-common/types/IconName'; +import { render, screen } from '@testing-library/react-native'; + +import { DefaultThemeProvider } from '../../utils/testHelpers'; +import { DEFAULT_ICON_FONT_FAMILY, type GlyphMap, IconGlyphSourceProvider } from '../createIcon'; +import { TextIcon } from '../TextIcon'; + +const INACTIVE_GLYPH = '\u2606'; // ☆ +const ACTIVE_GLYPH = '\u2605'; // ★ +const OTHER_GLYPH = '\u25B2'; // ▲ + +type DemoIconName = 'star'; + +const demoGlyphMap: GlyphMap = { + 'star-12-active': ACTIVE_GLYPH, + 'star-12-inactive': INACTIVE_GLYPH, + 'star-16-active': ACTIVE_GLYPH, + 'star-16-inactive': INACTIVE_GLYPH, + 'star-24-active': ACTIVE_GLYPH, + 'star-24-inactive': INACTIVE_GLYPH, +}; + +// TextIcon reads directly from the CDS glyphMap module; mock it so tests are +// independent of the published icon set. +jest.mock('@coinbase/cds-icons/glyphMap', () => ({ + glyphMap: { + 'star-12-active': '\u2605', + 'star-12-inactive': '\u2606', + 'star-16-active': '\u2605', + 'star-16-inactive': '\u2606', + 'star-24-active': '\u2605', + 'star-24-inactive': '\u2606', + }, +})); + +const renderTextIcon = (ui: React.ReactElement) => + render({ui}); + +describe('TextIcon', () => { + it('renders the CDS glyph by default', () => { + renderTextIcon(); + expect(screen.getByText(INACTIVE_GLYPH)).toBeTruthy(); + }); + + it('uses the default CDS font family', () => { + renderTextIcon(); + expect(screen.getByText(INACTIVE_GLYPH)).toHaveStyle({ fontFamily: DEFAULT_ICON_FONT_FAMILY }); + }); + + it('uses the glyph and font family from the context source when the name matches', () => { + renderTextIcon( + + + , + ); + + expect(screen.getByText(OTHER_GLYPH)).toHaveStyle({ fontFamily: 'RetailIcons' }); + expect(screen.queryByText(INACTIVE_GLYPH)).toBeNull(); + }); + + it('falls back to the CDS glyphMap when the context source does not cover the name', () => { + renderTextIcon( + + + , + ); + + expect(screen.getByText(INACTIVE_GLYPH)).toHaveStyle({ + fontFamily: DEFAULT_ICON_FONT_FAMILY, + }); + }); + + it('uses a custom getGlyph resolver from the context source', () => { + const getGlyph = jest.fn(() => OTHER_GLYPH); + renderTextIcon( + + + , + ); + + expect(screen.getByText(OTHER_GLYPH)).toHaveStyle({ fontFamily: 'CustomFont' }); + expect(getGlyph).toHaveBeenCalledWith(expect.objectContaining({ name: 'star', active: false })); + }); + + it('returns null when no glyph is found in either the context source or CDS glyphMap', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + renderTextIcon(); + expect(screen.queryByRole('image')).toBeNull(); + consoleError.mockRestore(); + }); +}); diff --git a/packages/mobile/src/icons/createIcon.tsx b/packages/mobile/src/icons/createIcon.tsx index 7655c9caa3..1f9e3ea3f6 100644 --- a/packages/mobile/src/icons/createIcon.tsx +++ b/packages/mobile/src/icons/createIcon.tsx @@ -52,7 +52,7 @@ export type IconGlyphSource = { getGlyph?: (args: IconGlyphResolverArgs) => string | undefined; }; -const IconGlyphSourceContext = createContext | undefined>(undefined); +export const IconGlyphSourceContext = createContext | undefined>(undefined); export type IconGlyphSourceProviderProps = { /** From f57a101d6326dbdf63257252a4bfd31676701782 Mon Sep 17 00:00:00 2001 From: Adrien Zheng Date: Wed, 26 Aug 2026 12:18:24 -0400 Subject: [PATCH 2/3] release --- packages/common/CHANGELOG.md | 4 ++++ packages/common/package.json | 2 +- packages/mcp-server/CHANGELOG.md | 4 ++++ packages/mcp-server/package.json | 2 +- packages/mobile/CHANGELOG.md | 6 ++++++ packages/mobile/package.json | 2 +- packages/web/CHANGELOG.md | 4 ++++ packages/web/package.json | 2 +- 8 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index de05f1630a..6e8c536468 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.23.0 ((8/26/2026, 09:18 AM PST)) + +This is an artificial version bump with no new change. + ## 9.22.0 ((8/25/2026, 08:11 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 4c4ea85c88..17536768a5 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "9.22.0", + "version": "9.23.0", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 8924eb86ba..bbfc2dca84 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.23.0 ((8/26/2026, 09:18 AM PST)) + +This is an artificial version bump with no new change. + ## 9.22.0 ((8/25/2026, 08:11 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 a82c527b18..553f38ce95 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "9.22.0", + "version": "9.23.0", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 756e8451a2..35b62756a1 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.23.0 (8/26/2026 PST) + +#### 🚀 Updates + +- Let TextIcon respect IconGlyphSourceContext overrides. [[#862](https://github.com/coinbase/cds/pull/862)] + ## 9.22.0 (8/25/2026 PST) #### 🚀 Updates diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 824e0d0ffe..87cf42ab33 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "9.22.0", + "version": "9.23.0", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 2fed37d611..ace9314fd7 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.23.0 ((8/26/2026, 09:18 AM PST)) + +This is an artificial version bump with no new change. + ## 9.22.0 (8/25/2026 PST) #### 🚀 Updates diff --git a/packages/web/package.json b/packages/web/package.json index 619667eb27..48ee7ca7aa 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "9.22.0", + "version": "9.23.0", "description": "Coinbase Design System - Web", "repository": { "type": "git", From 158eaf7445fc6631ae96097bccbfb5fdb9b1210b Mon Sep 17 00:00:00 2001 From: Adrien Zheng Date: Wed, 26 Aug 2026 13:01:50 -0400 Subject: [PATCH 3/3] refactor(mobile): extract useResolvedGlyph hook to share context logic between Icon and TextIcon Co-authored-by: Cursor --- packages/mobile/src/icons/TextIcon.tsx | 47 +++++++++--------------- packages/mobile/src/icons/createIcon.tsx | 16 +++++++- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/packages/mobile/src/icons/TextIcon.tsx b/packages/mobile/src/icons/TextIcon.tsx index ebbbd012e9..f3b3b03aee 100644 --- a/packages/mobile/src/icons/TextIcon.tsx +++ b/packages/mobile/src/icons/TextIcon.tsx @@ -1,4 +1,4 @@ -import React, { memo, useContext, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { Animated, Text } from 'react-native'; import type { StyleProp, TextStyle } from 'react-native'; import type { IconName } from '@coinbase/cds-common/types/IconName'; @@ -8,7 +8,7 @@ import { isDevelopment } from '@coinbase/cds-utils'; import { useTheme } from '../hooks/useTheme'; import type { IconProps } from './Icon'; -import { DEFAULT_ICON_FONT_FAMILY, getIconSourceSize, IconGlyphSourceContext } from './createIcon'; +import { DEFAULT_ICON_FONT_FAMILY, getIconSourceSize, useResolvedGlyph } from './createIcon'; export type TextIconProps = Pick & { name: IconName; @@ -23,6 +23,10 @@ export type TextIconProps = Pick & { style?: StyleProp; } ); + +/** Stable bound source so TextIcon participates in the same context resolution as Icon. */ +const cdsGlyphSource = { glyphMap, fontFamily: DEFAULT_ICON_FONT_FAMILY }; + /** * * This is a simplified, text-only version of the Icon component. @@ -41,56 +45,39 @@ export const TextIcon = memo(function TextIcon({ const theme = useTheme(); const Component = animated ? Animated.Text : Text; const iconSize = theme.iconSize[size]; - const sourceSize = getIconSourceSize(iconSize); const iconColor = theme.color[color]; - const contextSource = useContext(IconGlyphSourceContext); - - const iconKey = `${name}-${sourceSize}-${active ? 'active' : 'inactive'}`; - - // Context source (e.g. retail-icons override) takes priority over the CDS glyphMap. - const contextGlyph = contextSource - ? contextSource.getGlyph - ? contextSource.getGlyph({ - glyphMap: contextSource.glyphMap, - name, - size, - pixelSize: iconSize, - active: Boolean(active), - }) - : contextSource.glyphMap[iconKey as keyof typeof contextSource.glyphMap] - : undefined; - - const glyph = contextGlyph ?? glyphMap[iconKey as keyof typeof glyphMap]; - const fontFamily = - contextGlyph !== undefined - ? (contextSource?.fontFamily ?? DEFAULT_ICON_FONT_FAMILY) - : DEFAULT_ICON_FONT_FAMILY; + const resolved = useResolvedGlyph(cdsGlyphSource, { + name, + size, + pixelSize: iconSize, + active: Boolean(active), + }); const styles = useMemo( () => [ { - fontFamily, + fontFamily: resolved?.fontFamily, fontSize: iconSize, color: iconColor, }, style, // TODO https://linear.app/coinbase/issue/CDS-1518/audit-potentially-harmful-reactnative-animated-pattern ] as StyleProp, - [style, iconColor, iconSize, fontFamily], + [style, iconColor, iconSize, resolved?.fontFamily], ); - if (glyph === undefined) { + if (resolved === undefined) { if (isDevelopment()) { - console.error(`Unable to find glyph for icon name "${name}" with glyph key "${iconKey}"`); + console.error(`Unable to find glyph for icon name "${name}" at size "${size}"`); } return null; } return ( - {glyph} + {resolved.char} ); }); diff --git a/packages/mobile/src/icons/createIcon.tsx b/packages/mobile/src/icons/createIcon.tsx index 1f9e3ea3f6..6cfaa3253e 100644 --- a/packages/mobile/src/icons/createIcon.tsx +++ b/packages/mobile/src/icons/createIcon.tsx @@ -161,6 +161,19 @@ const resolveGlyph = ( return fromContext ?? resolveFromSource(boundSource, args); }; +/** + * Resolves a glyph for `name` against the nearest `IconGlyphSourceProvider`, + * falling back to `boundSource` when the context has no match. + * Extracts the shared lookup logic so both `createIcon` and `TextIcon` use it. + */ +export function useResolvedGlyph( + boundSource: IconGlyphSource, + args: Omit, 'glyphMap'>, +): ResolvedGlyph | undefined { + const contextSource = useContext(IconGlyphSourceContext); + return resolveGlyph(contextSource, boundSource, args); +} + /** Creates a typed `Icon` component bound to an icon set. */ export function createIcon(source: IconGlyphSource) { const Icon = memo(({ ref, ..._props }: IconProps & { ref?: React.Ref }) => { @@ -197,8 +210,7 @@ export function createIcon(source: IconGlyphSource) { const finalColor = dangerouslySetColor ?? iconColor; // Tried before the bound set, so a source can override a built-in icon. - const contextSource = useContext(IconGlyphSourceContext); - const resolved = resolveGlyph(contextSource, source, { + const resolved = useResolvedGlyph(source, { name, size, pixelSize: iconSize,