Refactor chart default typefaces behind shared provider#92534
Conversation
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
Co-authored-by: Cursor <cursoragent@cursor.com>
| function useChartDefaultTypeface(): ChartDefaultTypeface { | ||
| const context = useContext(ChartDefaultTypefaceContext); | ||
| if (!context) { | ||
| throw new Error('useChartDefaultTypeface must be used within ChartDefaultTypefaceProvider'); |
There was a problem hiding this comment.
Is this happening only for polar charts? I wasn't able to reproduce it for cartesian charts
There was a problem hiding this comment.
const reportID = '6681914213738705';
const actorAccountID = 22290834;
const html = `<victorychart domain="{x: [0.5, 12.5], y: [0, 2000]}" domainpadding="{x: 18, y: 0}" height="430" width="680" padding="{top: 126, bottom: 108, left: 96, right: 32}" style="{parent: { backgroundColor: '#F7F2EF', borderRadius: 16, width: '100%', maxWidth: 680}}"><victorylabel x="32" y="40" text='Monthly spend' style="{ fill: '#002E22', fontSize: 17, fontWeight: 700, fontFamily: 'Expensify Neue', }"/><victorylabel x="32" y="62" text='As of: May 6, 12:49 PM PT' style="{ fill: '#73857E', fontSize: 11, fontWeight: 400, fontFamily: 'Expensify Neue', }"/><victorybar barwidth="11" cornerradius="{top: 6, bottom: 6}" style="{data: {fill: '#1E90F2'}}" data="[ {x: 1.13, y: 1080}, {x: 2.13, y: 1225}, {x: 3.13, y: 1375}, {x: 4.13, y: 1600}, {x: 5.13, y: 1630}, {x: 6.13, y: 1725}, {x: 7.13, y: 1400}, {x: 8.13, y: 1685}, {x: 9.13, y: 1725}, {x: 10.13, y: 1800}, {x: 11.13, y: 1800}, {x: 12.13, y: 1800}, ]" labels="[ 'Jan 2025: $1,080', 'Feb 2025: $1,225', 'Mar 2025: $1,375', 'Apr 2025: $1,600', 'May 2025: $1,630', 'Jun 2025: $1,725', 'Jul 2025: $1,400', 'Aug 2025: $1,685', 'Sep 2025: $1,725', 'Oct 2025: $1,800', 'Nov 2025: $1,800', 'Dec 2025: $1,800', ]"/><victorybar barwidth="11" cornerradius="{top: 6, bottom: 6}" style="{data: {fill: '#13C96B'}}" data="[ {x: 0.87, y: 1200}, {x: 1.87, y: 1320}, {x: 2.87, y: 1570}, ]" labels="['Jan 2026: $1,200', 'Feb 2026: $1,320', 'Mar 2026: $1,570']"/><victorybar barwidth="11" cornerradius="{top: 6, bottom: 6}" style="{data: {fill: '#FF6A00'}}" data="[{x: 3.87, y: 1820}]" labels="['Apr 2026: $1,820']"/><victoryaxis tickvalues="[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]" tickformat="['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']" style="{ axis: {stroke: '#E6E1DA', strokeWidth: 1}, ticks: {stroke: 'transparent'}, tickLabels: { fill: '#73857E', fontSize: 11, fontWeight: 500, fontFamily: 'Expensify Neue', padding: 16, }, }"/><victoryaxis dependentaxis tickvalues="[0, 500, 1000, 1500, 2000]" tickformat="['$0', '$500', '$1,000', '$1,500', '$2,000']" style="{ axis: {stroke: 'transparent'}, ticks: {stroke: 'transparent'}, grid: {stroke: '#E6E1DA', strokeWidth: 1}, tickLabels: { fill: '#73857E', fontSize: 11, fontWeight: 500, fontFamily: 'Expensify Neue', padding: 28, }, }"/><victorylegend x="150" y="378" orientation="horizontal" gutter="34" symbolspacer="10" style="{ labels: { fill: '#002E22', fontSize: 13, fontWeight: 500, fontFamily: 'Expensify Neue', }, }" data="[ {name: '2026 spend', symbol: {type: 'circle', fill: '#13C96B', size: 6}}, {name: '2025 spend', symbol: {type: 'circle', fill: '#1E90F2', size: 6}}, {name: 'Last month', symbol: {type: 'circle', fill: '#FF6A00', size: 6}}, ]"/></victorychart>`;
Onyx.merge(`reportActions_${reportID}`, {
'12345678': {
message: [{html, text: 'chart test', type: 'COMMENT'}],
actionName: 'ADDCOMMENT',
actorAccountID,
automatic: false,
avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_5.png',
created: '2026-05-22 19:59:07.664',
lastModified: '2026-05-22 19:59:07.664',
reportActionID: '12345678',
reportID,
shouldShow: true,
},
});
const html2 = `<victorychart domain="{y: [0, 18000]}" domainpadding="{x: 44, y: 16}" height="464" width="680" padding="{top: 92, bottom: 84, left: 150, right: 32}" style="{ parent: { backgroundColor: '#F7F2EF', borderRadius: 16, width: '100%', maxWidth: 680, }, }" categories="{x: ['Ethan Brooks', 'Sofia Ramirez', 'Michelina Della Donna', 'Priya Patel', 'Alex Chen']}"><victorylabel x="32" y="40" text='Top employees by spend' style="{ fill: '#002E22', fontSize: 17, fontWeight: 700, fontFamily: 'Expensify Neue', }"/><victorylabel x="32" y="62" text='As of: May 6, 12:49 PM PT' style="{ fill: '#73857E', fontSize: 11, fontWeight: 400, fontFamily: 'Expensify Neue', }"/><victoryaxis tickValues="[0,1,2,3,4]" tickformat="['Ethan Brooks', 'Sofia Ramirez', 'Michelina Della Donna', 'Priya Patel', 'Alex Chen']" style="{ axis: {stroke: 'transparent'}, ticks: {stroke: 'transparent'}, grid: {stroke: 'transparent'}, tickLabels: { fill: '#73857E', fontSize: 11, fontWeight: 500, fontFamily: 'Expensify Neue', padding: 24, }, }"/><victoryaxis dependentaxis tickvalues="[0, 3000, 6000, 9000, 12000, 15000, 18000]" tickformat="['$0', '$3,000', '$6,000', '$9,000', '$12,000', '$15,000', '$18,000']" style="{ axis: {stroke: 'transparent'}, ticks: {stroke: 'transparent'}, grid: {stroke: '#E6E1DA', strokeWidth: 1}, tickLabels: { fill: '#73857E', fontSize: 11, fontWeight: 500, fontFamily: 'Expensify Neue', padding: 8, }, }"/><victorygroup horizontal offset="18"><victorybar barwidth="16" cornerradius="{top: 8, bottom: 0}" style="{data: {fill: '#13C96B'}}" data="[ {x: 'Ethan Brooks', y: 9450}, {x: 'Sofia Ramirez', y: 10500}, {x: 'Michelina Della Donna', y: 11900}, {x: 'Priya Patel', y: 13400}, {x: 'Alex Chen', y: 14300}, ]"/><victorybar barwidth="16" cornerradius="{top: 8, bottom: 0}" style="{data: {fill: '#1E90F2'}}" data="[ {x: 'Ethan Brooks', y: 8800}, {x: 'Sofia Ramirez', y: 10250}, {x: 'Michelina Della Donna', y: 11250}, {x: 'Priya Patel', y: 13900}, {x: 'Alex Chen', y: 12500}, ]"/></victorygroup><victorylegend x="250" y="416" orientation='horizontal' gutter="42" symbolspacer="10" style="{ labels: { fill: '#002E22', fontSize: 13, fontWeight: 500, fontFamily: 'Expensify Neue', }, }" data="[ {name: 'This month', symbol: {type: 'circle', fill: '#13C96B', size: 6}}, {name: 'Last month', symbol: {type: 'circle', fill: '#1E90F2', size: 6}}, ]"/></victorychart>`;
Onyx.merge(`reportActions_${reportID}`, {
'23456789': {
message: [{html: html2, text: 'chart test 2', type: 'COMMENT'}],
actionName: 'ADDCOMMENT',
actorAccountID,
automatic: false,
avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_5.png',
created: '2026-05-24 19:59:07.664',
lastModified: '2026-05-24 19:59:07.664',
reportActionID: '23456789',
reportID,
shouldShow: true,
},
});
const html3 = `<victorychart horizontal domain="{y: [0, 18000]}" domainpadding="{x: 44, y: 16}" height="464" width="680" padding="{top: 92, bottom: 84, left: 150, right: 32}" style="{ parent: { backgroundColor: '#F7F2EF', borderRadius: 16, width: '100%', maxWidth: 680, }, }" categories="{x: ['Ethan Brooks', 'Sofia Ramirez', 'Michelina Della Donna', 'Priya Patel', 'Alex Chen']}"><victorylabel x="32" y="40" text='Top employees by spend' style="{ fill: '#002E22', fontSize: 17, fontWeight: 700, fontFamily: 'Expensify Neue', }"/><victorylabel x="32" y="62" text='As of: May 6, 12:49 PM PT' style="{ fill: '#73857E', fontSize: 11, fontWeight: 400, fontFamily: 'Expensify Neue', }"/><victoryaxis tickValues="[0,1,2,3,4]" tickformat="['Ethan Brooks', 'Sofia Ramirez', 'Michelina Della Donna', 'Priya Patel', 'Alex Chen']" style="{ axis: {stroke: 'transparent'}, ticks: {stroke: 'transparent'}, grid: {stroke: 'transparent'}, tickLabels: { fill: '#73857E', fontSize: 11, fontWeight: 500, fontFamily: 'Expensify Neue', padding: 24, }, }"/><victoryaxis dependentaxis tickvalues="[0, 3000, 6000, 9000, 12000, 15000, 18000]" tickformat="['$0', '$3,000', '$6,000', '$9,000', '$12,000', '$15,000', '$18,000']" style="{ axis: {stroke: 'transparent'}, ticks: {stroke: 'transparent'}, grid: {stroke: '#E6E1DA', strokeWidth: 1}, tickLabels: { fill: '#73857E', fontSize: 11, fontWeight: 500, fontFamily: 'Expensify Neue', padding: 8, }, }"/><victorygroup horizontal offset="18"><victorybar barwidth="16" cornerradius="{top: 8, bottom: 0}" style="{data: {fill: '#13C96B'}}" data="[ {x: 'Ethan Brooks', y: 9450}, {x: 'Sofia Ramirez', y: 10500}, {x: 'Michelina Della Donna', y: 11900}, {x: 'Priya Patel', y: 13400}, {x: 'Alex Chen', y: 14300}, ]"/><victorybar barwidth="16" cornerradius="{top: 8, bottom: 0}" style="{data: {fill: '#1E90F2'}}" data="[ {x: 'Ethan Brooks', y: 8800}, {x: 'Sofia Ramirez', y: 10250}, {x: 'Michelina Della Donna', y: 11250}, {x: 'Priya Patel', y: 13900}, {x: 'Alex Chen', y: 12500}, ]"/></victorygroup><victorylegend x="250" y="416" orientation='horizontal' gutter="42" symbolspacer="10" style="{ labels: { fill: '#002E22', fontSize: 13, fontWeight: 500, fontFamily: 'Expensify Neue', }, }" data="[ {name: 'This month', symbol: {type: 'circle', fill: '#13C96B', size: 6}}, {name: 'Last month', symbol: {type: 'circle', fill: '#1E90F2', size: 6}}, ]"/></victorychart>`;
Onyx.merge(`reportActions_${reportID}`, {
'34567890': {
message: [{html: html3, text: 'chart test 3', type: 'COMMENT'}],
actionName: 'ADDCOMMENT',
actorAccountID,
automatic: false,
avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_5.png',
created: '2026-05-25 19:59:07.664',
lastModified: '2026-05-25 19:59:07.664',
reportActionID: '34567890',
reportID,
shouldShow: true,
},
});
const html4 = `<victorychart width="680" height="530" padding="{top: 20, bottom: 0, left: 0, right: 0}" domainpadding="0" style="{ parent: { backgroundColor: '#F7F2EF', borderRadius: 16, width: '100%', maxWidth: 680, }, }"><victoryaxis style="{ axis: {stroke: 'transparent'}, ticks: {stroke: 'transparent'}, grid: {stroke: 'transparent'}, tickLabels: {fill: 'transparent'}, }}"/><victoryaxis dependentaxis style="{ axis: {stroke: 'transparent'}, ticks: {stroke: 'transparent'}, grid: {stroke: 'transparent'}, tickLabels: {fill: 'transparent'}, }}"/><victorylabel x="32" y="40" text='Top categories' style="{ fill: '#002E22', fontSize: 17, fontWeight: 700, fontFamily: 'Expensify Neue' }"/><victorylabel x="32" y="62" text='As of: May 6, 12:49 PM PT' style="{ fill: '#76847E', fontSize: 11, fontWeight: 400, fontFamily: 'Expensify Neue' }"/><victorypie standalone="false" width="680" height="550" padding="{top: 0, bottom: 0, left: 0, right: 0}" innerradius="125" padangle="0.5" radius="145" labelindicatorinneroffset="15" labelindicatorouteroffset="8" colorscale="['#FED607', '#FF7101', '#F68DFE', '#03D47C', '#50EEF6', '#0185FF']" data="[ {x: 'Other', y: 309700}, {x: 'Postage And Delivery', y: 255800}, {x: 'Rodrigo Test', y: 178900}, {x: 'Travel Meals', y: 165800}, {x: 'Other Costs - COS', y: 58700}, {x: 'Prepaid Expenses', y: 39900}, ]" labels="['Other\\n$309,700', 'Postage And Delivery\\n$255,800', 'Rodrigo Test\\n$178,900', 'Travel Meals\\n$165,800', 'Other Costs - COS\\n$58,700', 'Prepaid Expenses\\n$39,900']" labelcomponent="<victorylabel lineheight="[1.2, 1.4]" style="[{fill: '#002E22', fontSize: 11, fontWeight: 700, fontFamily: 'Expensify Neue'}, {fill: '#76847E', fontSize: 11, fontWeight: 400, fontFamily: 'Expensify Neue'}]" />" labelRadius="180" style="{ data: { stroke: '#F7F2EF' }, }"/><victorylabel x="340" y="265" textanchor='middle' verticalanchor='middle' text="Total\n$1,008,800" lineheight="[1.23, 1.27]" style="[ { fill: '#76847E', fontSize: 13, fontFamily: 'Expensify Neue' }, { fill: '#002E22', fontSize: 26, fontFamily: 'Expensify New Kansas' }, ]"/></victorychart>`;
Onyx.merge(`reportActions_${reportID}`, {
'45678901': {
message: [{html: html4, text: 'chart test 4', type: 'COMMENT'}],
actionName: 'ADDCOMMENT',
actorAccountID,
automatic: false,
avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_5.png',
created: '2026-05-26 19:59:07.664',
lastModified: '2026-05-26 19:59:07.664',
reportActionID: '45678901',
reportID,
shouldShow: true,
},
});
}, 5000);
Inject this onyx data with your reportID. This has all new victory charts we recently implemented.
There was a problem hiding this comment.
Polar charts also use Expensify New Kansas
There was a problem hiding this comment.
thanks! That repro worked. Working on a fix
There was a problem hiding this comment.
ok, It seems like the problem was that renderOutside uses some separate skia renderer that's not tied to the same React tree as the rest of the app. I've got a local fix that we can push forward now, but I'll also start working on an upstream fix.
Co-authored-by: Cursor <cursoragent@cursor.com>
Polar chart HTML specifies fontFamily per label line; parse it and select the Kansas typeface from ChartDefaultTypefaceProvider. Co-authored-by: Cursor <cursoragent@cursor.com>
|
|
actually, I need to expand the context value to include all font family/weight/style combos |
Expose every FontUtils family variant (Neue, Mono, Kansas, emoji) and resolve Victory label styles via getChartSkiaTypeface. Co-authored-by: Cursor <cursoragent@cursor.com>
pecanoro
left a comment
There was a problem hiding this comment.
@roryabraham Why [No QA] on the title? QA could check that it didn't cause regressions and is still showing well, right?
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppandroid.movAndroid: mWeb Chromemchrome.moviOS: HybridAppios1.movios2.moviOS: mWeb Safarimsafari.movMacOS: Chrome / Safariweb.mov |
Regression Analysis🔴 R1 — One failed font asset now blanks all chart typography (fault coupling)This is the most impactful regression. Before, label typefaces and the Paragraph
After this PR,
Concrete consequences:
Fix: make per-asset decode resilient ( 🟠 R2 —
|
|
Codex Review: Didn't find any major issues. Can't wait for the next one! ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
@situchan that's a great point, but I don't think it necessarily needs to be a blocker, since failed font loads are an edge case. Maybe we can address it with a followup issue? |
yes agree |
|
Regarding this one:
It would be easy to change the array order, but I don't want to lose my internal approvals by pushing something new if it's not necessary. Maybe could be another follow-up. |
Code reviewSolid, well-contained refactor — the global single-flight cache + Suggestions1. Font loading is all-or-nothing, and the failure is cached (medium)
There's also a recovery wrinkle: on failure Consider 2.
3. Pie label template shares nested objects across slices (low / latent)
4. Unrecognized
Things I checked that are correct
|
Only two characters can actually shift appearance — ₺ and ₿ — and only if charts ever render Turkish Lira or Bitcoin amounts in labels/legends. For those, the symbol moves from Noto Sans to Kansas Medium (a display face) sitting next to Neue digits. Minor cosmetic inconsistency, not a breakage. The ◊ case is an improvement (renders instead of tofu).
Sure, definitely not blocker. Worth noting:
|
situchan
left a comment
There was a problem hiding this comment.
All bot reviews are non blockers so approving
|
@pecanoro to my knowledge, not all of these charts are readily available on staging yet. But I added some clear QA steps for what is definitely available. |
|
Merging through unrelated failing jest test, as agreed above |
|
@roryabraham looks like this was merged without a test passing. Please add a note explaining why this was done and remove the |
|
🚧 @roryabraham has triggered a test Expensify/App build. You can view the workflow run here. |
|
Follow up issue for hardening font loading: #92610 |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
🚀 Deployed to staging by https://github.com/roryabraham in version: 9.3.99-0 🚀
Bundle Size Analysis (Sentry): |
Help site review — no changes requiredNo updates to This is an internal rendering refactor with no customer-facing behavior change: it centralizes how chart Skia typefaces are decoded/cached/registered ( @roryabraham, no help site PR was created because no documentation changes are required. If you believe a customer-facing behavior actually changed here, let me know and I'll draft the docs update. |

Explanation of Change
Centralizes chart Skia font loading in a global
chartFontsCache(single-flight decode,useSyncExternalStore) andChartFontsProvider, withuseChartTypefaces/useChartFontManagerreading shared context. Bar/Line charts and Victory HTML charts use the same cached typeface map andfontMgr.Font assets are decoded once via
CHART_SKIA_TYPEFACE_ASSETS;fontMgris built from those typefaces plus supplemental Noto symbol/month fonts (no duplicate Neue/Mono/Kansas loads).CHART_FONT_FAMILY_NAMESis shared withVictoryTheme(adds Expensify Mono and Expensify New Kansas for Paragraph API fallback).getChartSkiaTypefaceresolves Victory label/legend styles byfontFamily,fontStyle, andfontWeight(including string'bold', numeric semibold, and Kansas on polar charts).normalizeChartFontWeightkeeps parsers and the resolver aligned.Nested
ChartFontsProviderinstances wrap VictoryPolarChartchildren and cartesianrenderOutsidebecause those Skia subtrees do not inherit font context from the base renderer.VictoryChartPiemust not calluseVictoryChartContext; it parses the pielabelcomponentonce and passes a template intoVictoryChartPieLabel(prodtnode+sliceAPI). The provider accepts an injectablevalueprop for a future server-side chart renderer CLI (#91528).Fixed Issues
$ #91528
Tests
getChartSkiaTypefaceTest.ts,VictoryTheme.test.ts.Offline tests
N/A — chart rendering does not depend on network for typeface loading.
QA Steps
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectioncanBeMissingparam foruseOnyxtoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps./** comment above it */thisproperly so there are no scoping issues (i.e. foronClick={this.submit}the methodthis.submitshould be bound tothisin the constructor)thisare necessary to be bound (i.e. avoidthis.submit = this.submit.bind(this);ifthis.submitis never passed to a component event handler likeonClick)Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari