From f23a1424a0562253da58ba155d4c8fe676077ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tar=C4=B1k?= Date: Mon, 15 Jun 2026 16:54:53 +0200 Subject: [PATCH] Optimize primitive color processing --- .../__tests__/processColor-benchmark-itest.js | 61 ++++++++++++ .../StyleSheet/__tests__/processColor-test.js | 96 +++++++++++++++++++ .../Libraries/StyleSheet/processColor.js | 28 ++++++ 3 files changed, 185 insertions(+) create mode 100644 packages/react-native/Libraries/StyleSheet/__tests__/processColor-benchmark-itest.js diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processColor-benchmark-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processColor-benchmark-itest.js new file mode 100644 index 000000000000..06658ee45a6e --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processColor-benchmark-itest.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 + * @fantom_native_opt false + * @fantom_js_bytecode false + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import processColor from '../processColor'; +import * as Fantom from '@react-native/fantom'; + +const REPEATED_COLORS = [ + 'red', + 'blue', + '#1e83c9', + 'rgba(10, 20, 30, 0.4)', + 'hsl(318, 69%, 55%)', +]; + +const GENERATED_COLORS = Array.from( + {length: 2048}, + (_, i) => + `rgba(${(i % 256).toString()}, ${((i * 3) % 256).toString()}, ${( + (i * 7) % + 256 + ).toString()}, ${((i % 100) / 100).toFixed(2)})`, +); + +let benchmarkSink = 0; + +function processColors( + colors: ReadonlyArray, + iterations: number, +): void { + let result = 0; + for (let i = 0; i < iterations; i++) { + const color = processColor(colors[i % colors.length]); + if (typeof color === 'number') { + result += color; + } + } + benchmarkSink += result; + if (benchmarkSink > Number.MAX_SAFE_INTEGER) { + benchmarkSink = 0; + } +} + +Fantom.unstable_benchmark + .suite('processColor') + .test('process repeated primitive colors', () => { + processColors(REPEATED_COLORS, 1000); + }) + .test('process generated primitive colors', () => { + processColors(GENERATED_COLORS, 2048); + }); diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processColor-test.js b/packages/react-native/Libraries/StyleSheet/__tests__/processColor-test.js index 1c9d8f52fd6b..bef074e8b7fb 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/processColor-test.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processColor-test.js @@ -10,6 +10,39 @@ 'use strict'; +jest.mock('../../Utilities/NativePlatformConstantsAndroid', () => ({ + __esModule: true, + default: { + getConstants: () => ({ + reactNativeVersion: { + major: 1000, + minor: 0, + patch: 0, + prerelease: undefined, + }, + }), + }, +})); + +jest.mock('../../Utilities/NativePlatformConstantsIOS', () => ({ + __esModule: true, + default: { + getConstants: () => ({ + forceTouchAvailable: false, + interfaceIdiom: 'phone', + isTesting: true, + osVersion: '1.0', + reactNativeVersion: { + major: 1000, + minor: 0, + patch: 0, + prerelease: undefined, + }, + systemName: 'iOS', + }), + }, +})); + const {OS} = require('../../Utilities/Platform').default; const PlatformColorAndroid = // $FlowFixMe[missing-platform-support] @@ -122,4 +155,67 @@ describe('processColor', () => { }); } }); + + describe('primitive color cache', () => { + afterEach(() => { + jest.dontMock('@react-native/normalize-colors'); + jest.resetModules(); + }); + + it('should cache processed primitive colors', () => { + jest.resetModules(); + + const normalizeColorMock = jest.fn(() => 0xff0000ff); + jest.doMock('@react-native/normalize-colors', () => normalizeColorMock); + + const cachedProcessColor = require('../processColor').default; + + expect(cachedProcessColor('cached-red')).toEqual( + platformSpecific(0xffff0000), + ); + expect(cachedProcessColor('cached-red')).toEqual( + platformSpecific(0xffff0000), + ); + expect(normalizeColorMock).toHaveBeenCalledTimes(1); + }); + + it('should cache invalid primitive colors', () => { + jest.resetModules(); + + const normalizeColorMock = jest.fn(() => undefined); + jest.doMock('@react-native/normalize-colors', () => normalizeColorMock); + + const cachedProcessColor = require('../processColor').default; + + expect(cachedProcessColor('not-a-color')).toBeUndefined(); + expect(cachedProcessColor('not-a-color')).toBeUndefined(); + expect(normalizeColorMock).toHaveBeenCalledTimes(1); + }); + + it('should stop admitting primitive colors after reaching the cache bound', () => { + jest.resetModules(); + + const normalizeColorMock = jest.fn(() => 0xff0000ff); + jest.doMock('@react-native/normalize-colors', () => normalizeColorMock); + + const cachedProcessColor = require('../processColor').default; + + for (let i = 0; i < 1024; i++) { + cachedProcessColor(`cached-color-${i.toString()}`); + } + expect(normalizeColorMock).toHaveBeenCalledTimes(1024); + + cachedProcessColor('cached-color-0'); + expect(normalizeColorMock).toHaveBeenCalledTimes(1024); + + cachedProcessColor('cached-color-1024'); + expect(normalizeColorMock).toHaveBeenCalledTimes(1025); + + cachedProcessColor('cached-color-1024'); + expect(normalizeColorMock).toHaveBeenCalledTimes(1026); + + cachedProcessColor('cached-color-0'); + expect(normalizeColorMock).toHaveBeenCalledTimes(1026); + }); + }); }); diff --git a/packages/react-native/Libraries/StyleSheet/processColor.js b/packages/react-native/Libraries/StyleSheet/processColor.js index e2a2bf511901..55f43ab46324 100644 --- a/packages/react-native/Libraries/StyleSheet/processColor.js +++ b/packages/react-native/Libraries/StyleSheet/processColor.js @@ -17,14 +17,30 @@ const normalizeColor = require('./normalizeColor').default; export type ProcessedColorValue = number | NativeColorValue; +type CacheableColorValue = number | string; + +const MAX_PRIMITIVE_COLOR_CACHE_SIZE = 1024; +const primitiveColorCache: Map = + new Map(); + /* eslint no-bitwise: 0 */ function processColor(color?: ?(number | ColorValue)): ?ProcessedColorValue { if (color === undefined || color === null) { return color; } + if (typeof color === 'string' || typeof color === 'number') { + const cachedColor = primitiveColorCache.get(color); + if (cachedColor !== undefined || primitiveColorCache.has(color)) { + return cachedColor; + } + } + let normalizedColor = normalizeColor(color); if (normalizedColor === null || normalizedColor === undefined) { + if (typeof color === 'string' || typeof color === 'number') { + cachePrimitiveColor(color, undefined); + } return undefined; } @@ -53,7 +69,19 @@ function processColor(color?: ?(number | ColorValue)): ?ProcessedColorValue { // *unsigned* to *signed* 32bit int that way. normalizedColor = normalizedColor | 0x0; } + if (typeof color === 'string' || typeof color === 'number') { + cachePrimitiveColor(color, normalizedColor); + } return normalizedColor; } +function cachePrimitiveColor( + color: CacheableColorValue, + processedColor: ?ProcessedColorValue, +): void { + if (primitiveColorCache.size < MAX_PRIMITIVE_COLOR_CACHE_SIZE) { + primitiveColorCache.set(color, processedColor); + } +} + export default processColor;