From 938dd0f2e26d491699bd3bf4555eee971f9372bf Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Sun, 23 Aug 2026 11:20:59 +0700 Subject: [PATCH] fix(theme): keep flat font properties in a mixed configureFonts config --- src/theme/__tests__/fonts.test.js | 44 ++++++++++++++++++++++++++ src/theme/fonts.tsx | 52 +++++++++++++++++++------------ 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/src/theme/__tests__/fonts.test.js b/src/theme/__tests__/fonts.test.js index 23e0f794ae..86d1430405 100644 --- a/src/theme/__tests__/fonts.test.js +++ b/src/theme/__tests__/fonts.test.js @@ -340,6 +340,50 @@ describe('configureFonts', () => { }); }); + it('applies flat properties to every variant when the config also has per-variant entries', () => { + mockPlatform('ios'); + const { configureFonts, typescale } = loadFonts(); + + const fonts = configureFonts({ + config: { + fontFamily: 'NotoSans', + bodyLarge: { + fontSize: 18, + }, + }, + }); + + expect(fonts).toEqual({ + ...Object.fromEntries( + Object.entries(typescale).map(([variantName, variantProperties]) => [ + variantName, + { ...variantProperties, fontFamily: 'NotoSans' }, + ]) + ), + bodyLarge: { + ...typescale.bodyLarge, + fontFamily: 'NotoSans', + fontSize: 18, + }, + }); + }); + + it('does not add flat properties of a mixed config as typescale variants', () => { + mockPlatform('ios'); + const { configureFonts } = loadFonts(); + + const fonts = configureFonts({ + config: { + fontFamily: 'NotoSans', + bodyLarge: { + fontSize: 18, + }, + }, + }); + + expect(fonts.fontFamily).toBeUndefined(); + }); + it('should be deterministic', () => { mockPlatform('ios'); const { configureFonts } = loadFonts(); diff --git a/src/theme/fonts.tsx b/src/theme/fonts.tsx index 2fe7b55626..268f4dde02 100644 --- a/src/theme/fonts.tsx +++ b/src/theme/fonts.tsx @@ -13,34 +13,41 @@ function configureFontsConfig( return typescale; } - const isFlatConfig = Object.values(config).every( - (value) => typeof value !== 'object' - ); - - if (isFlatConfig) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return Object.fromEntries( - Object.entries(typescale).map(([variantName, variantProperties]) => [ - variantName, - { ...variantProperties, ...config }, - ]) - ) as Typescale; + // A config entry is either a whole variant (an object, e.g. `bodyLarge: { fontSize: 18 }`) + // or a single font property shared by every variant (e.g. `fontFamily: 'NotoSans'`). + // Both may appear in the same config, so they are collected separately instead of + // classifying the config as a whole. + const sharedProperties: Record = {}; + const variantOverrides: Record = {}; + + for (const [key, value] of Object.entries(config)) { + if (typeof value === 'object' && value !== null) { + variantOverrides[key] = value; + } else { + sharedProperties[key] = value; + } } const typescaleByVariant: Partial< Record > = typescale; - return Object.assign( - {}, - typescale, - ...Object.entries(config).map(([variantName, variantProperties]) => ({ - [variantName]: { + const variantNames = new Set([ + ...Object.keys(typescale), + ...Object.keys(variantOverrides), + ]); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return Object.fromEntries( + Array.from(variantNames, (variantName) => [ + variantName, + { ...typescaleByVariant[variantName], - ...variantProperties, + ...sharedProperties, + ...variantOverrides[variantName], }, - })) - ); + ]) + ) as Typescale; } export default function configureFonts(params?: { @@ -51,6 +58,11 @@ export default function configureFonts(params?: { config?: Partial>>; }): Typescale; // eslint-disable-next-line no-redeclare +export default function configureFonts(params: { + config: Partial & + Partial>>; +}): Typescale; +// eslint-disable-next-line no-redeclare export default function configureFonts(params: { config: Record; }): Typescale & { [key: string]: TypescaleStyle };