From d45f701e0886cceba382c2c60c6926e85a42bf66 Mon Sep 17 00:00:00 2001 From: Evan Katz Date: Thu, 13 Aug 2026 16:28:28 -0700 Subject: [PATCH] Accept object font variation settings (#57929) Summary: Allow `fontVariationSettings` to accept either the existing CSS-compatible string or an object keyed by four-character OpenType axis tags. Normalize the object at the shared style boundary so Text and TextInput retain the existing native string representation on every platform. Object serialization is deterministic, supports fractional finite values, and preserves the current unset versus explicit-clear behavior. Changelog: [General][Added] - Add object syntax for `fontVariationSettings` Reviewed By: Abbondanzo Differential Revision: D115640672 --- .../AndroidTextInputNativeComponent.js | 7 ++- .../TextInput/RCTTextInputViewConfig.js | 6 +- .../__tests__/RCTTextInputViewConfig-itest.js | 56 +++++++++++++++++ .../View/ReactNativeStyleAttributes.js | 7 ++- .../Libraries/StyleSheet/StyleSheetTypes.js | 12 ++-- .../processFontVariationSettings-itest.js | 63 +++++++++++++++++++ .../processFontVariationSettings.js | 61 ++++++++++++++++++ .../Libraries/Text/__tests__/Text-itest.js | 38 +++++++++++ packages/react-native/ReactNativeApi.d.ts | 23 ++++--- .../Libraries/StyleSheet/StyleSheetTypes.d.ts | 11 ++-- 10 files changed, 263 insertions(+), 21 deletions(-) create mode 100644 packages/react-native/Libraries/Components/TextInput/__tests__/RCTTextInputViewConfig-itest.js create mode 100644 packages/react-native/Libraries/StyleSheet/__tests__/processFontVariationSettings-itest.js create mode 100644 packages/react-native/Libraries/StyleSheet/processFontVariationSettings.js diff --git a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js index 680ff7aa974b..37f4fa6768c3 100644 --- a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js +++ b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js @@ -24,7 +24,10 @@ import type {TextInputNativeCommands} from './TextInputNativeCommands'; import * as NativeComponentRegistry from '../../NativeComponent/NativeComponentRegistry'; import codegenNativeCommands from '../../Utilities/codegenNativeCommands'; -import {colorAttribute} from '../View/ReactNativeStyleAttributes'; +import { + colorAttribute, + fontVariationSettingsAttribute, +} from '../View/ReactNativeStyleAttributes'; export type KeyboardType = // Cross Platform @@ -715,7 +718,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = { includeFontPadding: true, fontWeight: true, fontFamily: true, - fontVariationSettings: true, + fontVariationSettings: fontVariationSettingsAttribute, allowFontScaling: true, onSelectionChange: true, mostRecentEventCount: true, diff --git a/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js index cccc623c46ce..ada8aef2247a 100644 --- a/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js +++ b/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js @@ -11,7 +11,10 @@ import type {PartialViewConfig} from '../../Renderer/shims/ReactNativeTypes'; import {ConditionallyIgnoredEventHandlers} from '../../NativeComponent/ViewConfigIgnore'; -import {colorAttribute} from '../View/ReactNativeStyleAttributes'; +import { + colorAttribute, + fontVariationSettingsAttribute, +} from '../View/ReactNativeStyleAttributes'; type PartialViewConfigWithoutName = Omit; @@ -102,6 +105,7 @@ const RCTTextInputViewConfig: PartialViewConfigWithoutName = { }, allowFontScaling: true, fontStyle: true, + fontVariationSettings: fontVariationSettingsAttribute, textTransform: true, textAlign: true, fontFamily: true, diff --git a/packages/react-native/Libraries/Components/TextInput/__tests__/RCTTextInputViewConfig-itest.js b/packages/react-native/Libraries/Components/TextInput/__tests__/RCTTextInputViewConfig-itest.js new file mode 100644 index 000000000000..d4b79a57de33 --- /dev/null +++ b/packages/react-native/Libraries/Components/TextInput/__tests__/RCTTextInputViewConfig-itest.js @@ -0,0 +1,56 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import {__INTERNAL_VIEW_CONFIG as AndroidTextInputViewConfig} from '../AndroidTextInputNativeComponent'; +import RCTTextInputViewConfig from '../RCTTextInputViewConfig'; +import nullthrows from 'nullthrows'; + +const { + create, +} = require('../../../ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload'); + +const androidValidAttributes = nullthrows( + AndroidTextInputViewConfig.validAttributes, +); +const appleValidAttributes = nullthrows(RCTTextInputViewConfig.validAttributes); + +describe('Android TextInput view config', () => { + it('serializes object font variation settings', () => { + expect( + create( + {fontVariationSettings: {wght: 552.5, opsz: 17.25}}, + androidValidAttributes, + ), + ).toEqual({fontVariationSettings: "'opsz' 17.25, 'wght' 552.5"}); + }); + + it('serializes an empty object as an explicit clear', () => { + expect(create({fontVariationSettings: {}}, androidValidAttributes)).toEqual( + {fontVariationSettings: ''}, + ); + }); +}); + +describe('Apple TextInput view config', () => { + it('serializes object font variation settings', () => { + expect( + create( + {fontVariationSettings: {wght: 552.5, opsz: 17.25}}, + appleValidAttributes, + ), + ).toEqual({fontVariationSettings: "'opsz' 17.25, 'wght' 552.5"}); + }); + + it('serializes an empty object as an explicit clear', () => { + expect(create({fontVariationSettings: {}}, appleValidAttributes)).toEqual({ + fontVariationSettings: '', + }); + }); +}); diff --git a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js index 68d4fa451f56..d3883ea7b859 100644 --- a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js +++ b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js @@ -20,6 +20,7 @@ import processBoxShadow from '../../StyleSheet/processBoxShadow'; import processColor from '../../StyleSheet/processColor'; import processFilter from '../../StyleSheet/processFilter'; import processFontVariant from '../../StyleSheet/processFontVariant'; +import processFontVariationSettings from '../../StyleSheet/processFontVariationSettings'; import processTransform from '../../StyleSheet/processTransform'; import processTransformOrigin from '../../StyleSheet/processTransformOrigin'; import sizesDiffer from '../../Utilities/differ/sizesDiffer'; @@ -71,6 +72,10 @@ export const fontVariantAttribute: AnyAttributeType = nativeCSSParsing ? true : {process: processFontVariant}; +export const fontVariationSettingsAttribute: AnyAttributeType = { + process: processFontVariationSettings, +}; + export const aspectRatioAttribute: AnyAttributeType = nativeCSSParsing ? true : {process: processAspectRatio}; @@ -258,7 +263,7 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = { fontSize: true, fontStyle: true, fontVariant: fontVariantAttribute, - fontVariationSettings: true, + fontVariationSettings: fontVariationSettingsAttribute, fontWeight: true, includeFontPadding: true, letterSpacing: true, diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js index 9df143c6efe1..dfee208d2033 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js @@ -999,6 +999,9 @@ export type ____FontVariant_Internal = export type ____FontVariantArray_Internal = ReadonlyArray<____FontVariant_Internal>; +export type ____FontVariationSettings_Internal = + string | Readonly<{[axis: string]: number}>; + type ____TextStyle_InternalBase = Readonly<{ color?: ____ColorValue_Internal, fontFamily?: string, @@ -1012,11 +1015,12 @@ type ____TextStyle_InternalBase = Readonly<{ fontWeight?: ____FontWeight_Internal, fontVariant?: ____FontVariantArray_Internal | string, /** - * Specifies OpenType font variation axis values using CSS syntax. An empty - * string resets inherited variation settings. On Android, this requires API - * level 26 or later. + * Specifies OpenType font variation axis values using CSS syntax or an + * object keyed by four-character axis tags. An empty string or object resets + * inherited variation settings. On Android, this requires API level 26 or + * later. */ - fontVariationSettings?: string, + fontVariationSettings?: ____FontVariationSettings_Internal, textShadowOffset?: Readonly<{ width: number, height: number, diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processFontVariationSettings-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processFontVariationSettings-itest.js new file mode 100644 index 000000000000..f9c4f2dbe8df --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processFontVariationSettings-itest.js @@ -0,0 +1,63 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import processFontVariationSettings from '../processFontVariationSettings'; + +describe('processFontVariationSettings', () => { + it('passes string settings through unchanged', () => { + expect(processFontVariationSettings("'wght' 550, 'opsz' 18")).toBe( + "'wght' 550, 'opsz' 18", + ); + }); + + it('serializes object settings in deterministic axis order', () => { + expect(processFontVariationSettings({wght: 552.5, opsz: 17.25})).toBe( + "'opsz' 17.25, 'wght' 552.5", + ); + }); + + it('serializes an empty object as an explicit clear', () => { + expect(processFontVariationSettings({})).toBe(''); + }); + + it('supports printable four-character tags containing a single quote', () => { + expect(processFontVariationSettings({["a'b "]: 1})).toBe('"a\'b " 1'); + }); + + it('preserves backslashes in printable four-character tags', () => { + expect(processFontVariationSettings({['a\\bc']: 1})).toBe("'a\\bc' 1"); + }); + + it('rejects tags that cannot be delimited without escaping', () => { + expect(() => processFontVariationSettings({[`a'"b`]: 1})).toThrow( + 'Font variation axis tags containing both quote characters must use the string form: "a\'\\"b"', + ); + }); + + it('rejects invalid axis tags', () => { + expect(() => processFontVariationSettings({weight: 550})).toThrow( + 'Font variation axis tags must be exactly four printable ASCII characters: "weight"', + ); + expect(() => processFontVariationSettings({['a\nbc']: 1})).toThrow( + 'Font variation axis tags must be exactly four printable ASCII characters: "a\\nbc"', + ); + }); + + it('rejects non-finite axis values', () => { + expect(() => processFontVariationSettings({wght: NaN})).toThrow( + 'Font variation axis values must be finite numbers: NaN', + ); + expect(() => processFontVariationSettings({wght: Infinity})).toThrow( + 'Font variation axis values must be finite numbers: Infinity', + ); + }); +}); diff --git a/packages/react-native/Libraries/StyleSheet/processFontVariationSettings.js b/packages/react-native/Libraries/StyleSheet/processFontVariationSettings.js new file mode 100644 index 000000000000..cd59f0192480 --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/processFontVariationSettings.js @@ -0,0 +1,61 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import type {____FontVariationSettings_Internal} from './StyleSheetTypes'; + +function quoteAxis(axis: string): string { + if ( + axis.length !== 4 || + axis.split('').some(character => { + const code = character.charCodeAt(0); + return code < 0x20 || code > 0x7e; + }) + ) { + throw new Error( + `Font variation axis tags must be exactly four printable ASCII characters: ${JSON.stringify(axis)}`, + ); + } + + if (!axis.includes("'")) { + return `'${axis}'`; + } + if (!axis.includes('"')) { + return `"${axis}"`; + } + + throw new Error( + `Font variation axis tags containing both quote characters must use the string form: ${JSON.stringify(axis)}`, + ); +} + +function processFontVariationSettings( + settings: ____FontVariationSettings_Internal, +): string { + if (typeof settings === 'string') { + return settings; + } + + return Object.keys(settings) + .sort() + .map(axis => { + const value = settings[axis]; + if (!Number.isFinite(value)) { + throw new Error( + `Font variation axis values must be finite numbers: ${String(value)}`, + ); + } + return `${quoteAxis(axis)} ${String(value)}`; + }) + .join(', '); +} + +export default processFontVariationSettings; diff --git a/packages/react-native/Libraries/Text/__tests__/Text-itest.js b/packages/react-native/Libraries/Text/__tests__/Text-itest.js index a7a50140cd5a..2d19756ff935 100644 --- a/packages/react-native/Libraries/Text/__tests__/Text-itest.js +++ b/packages/react-native/Libraries/Text/__tests__/Text-itest.js @@ -52,6 +52,44 @@ describe('', () => { }); }); + describe('fontVariationSettings', () => { + it('serializes object settings', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + {TEST_TEXT} + , + ); + }); + + expect( + root.getRenderedOutput({props: ['fontVariationSettings']}).toJSX(), + ).toEqual( + + {TEST_TEXT} + , + ); + }); + + it('serializes an empty object as an explicit clear', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + {TEST_TEXT}, + ); + }); + + expect( + root.getRenderedOutput({props: ['fontVariationSettings']}).toJSX(), + ).toEqual( + {TEST_TEXT}, + ); + }); + }); + describe('adjustsFontSizeToFit', () => { it(`can be set to "true"`, () => { const root = Fantom.createRoot(); diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 5220c72e26e6..a04517cbd5a0 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<4406ddb15814262a7ccc2b56e9625d67>> + * @generated SignedSource<<44990574a74bcc122718d4f6142db8cd>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -582,6 +582,11 @@ declare type ____FontVariant_Internal = | "tabular-nums" declare type ____FontVariantArray_Internal = ReadonlyArray<____FontVariant_Internal> +declare type ____FontVariationSettings_Internal = + | string + | { + readonly [axis: string]: number + } declare type ____FontWeight_Internal = | "100" | "200" @@ -747,7 +752,7 @@ declare type ____TextStyle_InternalBase = { readonly fontSize?: number readonly fontStyle?: "italic" | "normal" readonly fontVariant?: ____FontVariantArray_Internal | string - readonly fontVariationSettings?: string + readonly fontVariationSettings?: ____FontVariationSettings_Internal readonly fontWeight?: ____FontWeight_Internal readonly includeFontPadding?: boolean readonly letterSpacing?: number @@ -5725,7 +5730,7 @@ export { AlertOptions, // 8a116d2a AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // d74ec583 + Animated, // f23abee5 AppConfig, // 35c0ca70 AppRegistry, // 1e8c5a00 AppState, // 12012be5 @@ -5948,7 +5953,7 @@ export { StatusBarProps, // c2a44d88 StatusBarStyle, // 78f53eea StyleProp, // fa0e9b4a - StyleSheet, // 3c21ec63 + StyleSheet, // daba2860 SubmitBehavior, // c4ddf490 Switch, // f495bab3 SwitchChangeEvent, // 899635b1 @@ -5958,9 +5963,9 @@ export { TVViewPropsIOS, // 330ce7b5 TargetedEvent, // 16e98910 TaskProvider, // 266dedf2 - Text, // 09e783b9 + Text, // 72e323cd TextContentType, // 239b3ecc - TextInput, // a2cc82d1 + TextInput, // e1821dbc TextInputAndroidProps, // 9ebbc103 TextInputBlurEvent, // b77af40e TextInputChangeEvent, // f55eef98 @@ -5970,13 +5975,13 @@ export { TextInputIOSProps, // fb3c9327 TextInputInstance, // 5a0c0e0d TextInputKeyPressEvent, // 546c5d07 - TextInputProps, // dd91ebaa + TextInputProps, // f6b0ba24 TextInputSelectionChangeEvent, // e58f2abc TextInputSubmitEditingEvent, // 6bcb2aa5 TextInstance, // 05463a96 TextLayoutEvent, // 3f54186f - TextProps, // 4a122c2b - TextStyle, // 6ad4307f + TextProps, // 921f7adc + TextStyle, // 01a21fd1 ToastAndroid, // 88a8969a TouchableHighlight, // 3a6eaed4 TouchableHighlightInstance, // b510c0eb diff --git a/packages/react-native/types_DEPRECATED/Libraries/StyleSheet/StyleSheetTypes.d.ts b/packages/react-native/types_DEPRECATED/Libraries/StyleSheet/StyleSheetTypes.d.ts index 8e17f07e04fc..04b13f46ad90 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/StyleSheet/StyleSheetTypes.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/StyleSheet/StyleSheetTypes.d.ts @@ -559,6 +559,8 @@ export interface TextStyleAndroid extends ViewStyle { } // @see https://reactnative.dev/docs/text#style +export type FontVariationSettings = string | Readonly>; + export interface TextStyle extends TextStyleIOS, TextStyleAndroid, ViewStyle { color?: ColorValue | undefined; fontFamily?: string | undefined; @@ -602,11 +604,12 @@ export interface TextStyle extends TextStyleIOS, TextStyleAndroid, ViewStyle { | 'black' | undefined; /** - * Specifies OpenType font variation axis values using CSS syntax. An empty - * string resets inherited variation settings. On Android, this requires API - * level 26 or later. + * Specifies OpenType font variation axis values using CSS syntax or an + * object keyed by four-character axis tags. An empty string or object resets + * inherited variation settings. On Android, this requires API level 26 or + * later. */ - fontVariationSettings?: string | undefined; + fontVariationSettings?: FontVariationSettings | undefined; letterSpacing?: number | undefined; lineHeight?: number | undefined; textAlign?: