From b48379ee3e8dc357592b9bea288aa1cfcd0c2c55 Mon Sep 17 00:00:00 2001 From: threadhoard-vps Date: Sat, 15 Aug 2026 19:14:01 +0000 Subject: [PATCH] Cache parsed box shadow strings --- .../processBoxShadow-benchmark-itest.js | 46 +++++++++ .../__tests__/processBoxShadow-test.js | 95 +++++++++++++++++++ .../Libraries/StyleSheet/processBoxShadow.js | 58 +++++++++-- 3 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 packages/react-native/Libraries/StyleSheet/__tests__/processBoxShadow-benchmark-itest.js create mode 100644 packages/react-native/Libraries/StyleSheet/__tests__/processBoxShadow-test.js diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBoxShadow-benchmark-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBoxShadow-benchmark-itest.js new file mode 100644 index 000000000000..36a43d967ae8 --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processBoxShadow-benchmark-itest.js @@ -0,0 +1,46 @@ +/** + * 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 processBoxShadow from '../processBoxShadow'; +import * as Fantom from '@react-native/fantom'; + +const REPEATED_BOX_SHADOW = + '0 1px 2px rgba(0, 0, 0, 0.2), inset 0 0 0 1px #ffffff'; + +let uniqueInputOffset = 0; + +function processRepeatedBoxShadows(count: number): void { + for (let i = 0; i < count; i++) { + processBoxShadow(REPEATED_BOX_SHADOW); + } +} + +function processUniqueBoxShadows(count: number): void { + const offset = uniqueInputOffset; + uniqueInputOffset += count; + for (let i = 0; i < count; i++) { + processBoxShadow(`${(offset + i).toString()}px 1px 2px rgba(0, 0, 0, 0.2)`); + } +} + +Fantom.unstable_benchmark + .suite('processBoxShadow') + .test.each( + [100, 1000], + count => `process the same string ${count.toString()} times`, + processRepeatedBoxShadows, + ) + .test.each( + [100, 1000], + count => `process ${count.toString()} unique strings`, + processUniqueBoxShadows, + ); diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBoxShadow-test.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBoxShadow-test.js new file mode 100644 index 000000000000..256e4594d70c --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processBoxShadow-test.js @@ -0,0 +1,95 @@ +/** + * 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 type {BoxShadowValue} from '../StyleSheetTypes'; + +import processBoxShadow from '../processBoxShadow'; +import * as ProcessColor from '../processColor'; + +describe('processBoxShadow cache', () => { + it('does not expose cached results to mutation', () => { + const value = '10px 5px 2px 3px red, inset 1px 2px blue'; + const expectedResult = [ + { + offsetX: 10, + offsetY: 5, + blurRadius: 2, + spreadDistance: 3, + color: ProcessColor.default('red'), + }, + { + offsetX: 1, + offsetY: 2, + color: ProcessColor.default('blue'), + inset: true, + }, + ]; + const firstResult = processBoxShadow(value); + + firstResult[0].offsetX = 100; + firstResult.pop(); + + const secondResult = processBoxShadow(value); + expect(secondResult).toEqual(expectedResult); + expect(secondResult).not.toBe(firstResult); + expect(secondResult[0]).not.toBe(firstResult[0]); + + secondResult[0].offsetX = 200; + secondResult.pop(); + + expect(processBoxShadow(value)).toEqual(expectedResult); + }); + + it('does not expose cached invalid results to mutation', () => { + const value = '1px invalid'; + const firstResult = processBoxShadow(value); + + firstResult.push({offsetX: 1, offsetY: 2}); + + const secondResult = processBoxShadow(value); + expect(secondResult).toEqual([]); + secondResult.push({offsetX: 3, offsetY: 4}); + expect(processBoxShadow(value)).toEqual([]); + }); + + it('evicts the least recently used string', () => { + const retainedValue = '98765px 2px'; + const evictedValue = '98764px 2px'; + const processColorSpy = jest.spyOn(ProcessColor, 'default'); + + processBoxShadow(evictedValue); + processBoxShadow(evictedValue); + processBoxShadow(retainedValue); + processBoxShadow(retainedValue); + + for (let i = 0; i < 600; i++) { + processBoxShadow(`${(100000 + i).toString()}px 2px`); + } + processBoxShadow(retainedValue); + for (let i = 600; i < 1100; i++) { + processBoxShadow(`${(100000 + i).toString()}px 2px`); + } + processColorSpy.mockClear(); + + processBoxShadow(retainedValue); + expect(processColorSpy).not.toHaveBeenCalled(); + processBoxShadow(evictedValue); + expect(processColorSpy).toHaveBeenCalled(); + processColorSpy.mockRestore(); + }); + + it('does not cache object inputs', () => { + const value: Array = [{offsetX: 1, offsetY: 2}]; + + expect(processBoxShadow(value)).toEqual([{offsetX: 1, offsetY: 2}]); + value[0].offsetX = 3; + expect(processBoxShadow(value)).toEqual([{offsetX: 3, offsetY: 2}]); + }); +}); diff --git a/packages/react-native/Libraries/StyleSheet/processBoxShadow.js b/packages/react-native/Libraries/StyleSheet/processBoxShadow.js index 75ea41c58a5d..2cc34ea48485 100644 --- a/packages/react-native/Libraries/StyleSheet/processBoxShadow.js +++ b/packages/react-native/Libraries/StyleSheet/processBoxShadow.js @@ -17,6 +17,8 @@ const COMMA_SPLIT_REGEX = /,(?![^()]*\))/; const WHITESPACE_SPLIT_REGEX = /\s+(?![^(]*\))/; const LENGTH_PARSE_REGEX = /^([+-]?\d*\.?\d+)(px)?$/; const NEWLINE_REGEX = /\n/g; +const MAX_CACHE_SIZE = 1024; +const CACHE_CANDIDATE: false = false; export type ParsedBoxShadow = { offsetX: number, @@ -27,18 +29,55 @@ export type ParsedBoxShadow = { inset?: boolean, }; +const boxShadowCache: Map> = new Map(); + export default function processBoxShadow( rawBoxShadows: ?(ReadonlyArray | string), ): Array { - const result: Array = []; if (rawBoxShadows == null) { - return result; + return []; + } + + if (typeof rawBoxShadows !== 'string') { + return processBoxShadowList(rawBoxShadows); + } + + const cachedResult = boxShadowCache.get(rawBoxShadows); + if (cachedResult != null && cachedResult !== CACHE_CANDIDATE) { + // Map iteration order follows insertion order. Re-inserting on a hit keeps + // the least-recently-used entry first. + boxShadowCache.delete(rawBoxShadows); + boxShadowCache.set(rawBoxShadows, cachedResult); + return cloneBoxShadows(cachedResult); } - const boxShadowList = - typeof rawBoxShadows === 'string' - ? parseBoxShadowString(rawBoxShadows.replace(NEWLINE_REGEX, ' ')) - : rawBoxShadows; + // Wait for a repeat before copying a parsed value into the cache. This keeps + // one-off strings from paying the cost of cloning the result. + const wasSeen = cachedResult === CACHE_CANDIDATE; + const result = processBoxShadowList( + parseBoxShadowString(rawBoxShadows.replace(NEWLINE_REGEX, ' ')), + ); + + if (wasSeen) { + boxShadowCache.delete(rawBoxShadows); + boxShadowCache.set(rawBoxShadows, cloneBoxShadows(result)); + } else { + if (boxShadowCache.size >= MAX_CACHE_SIZE) { + const oldestKey = boxShadowCache.keys().next().value; + if (oldestKey != null) { + boxShadowCache.delete(oldestKey); + } + } + boxShadowCache.set(rawBoxShadows, CACHE_CANDIDATE); + } + + return result; +} + +function processBoxShadowList( + boxShadowList: ReadonlyArray, +): Array { + const result: Array = []; for (const rawBoxShadow of boxShadowList) { const parsedBoxShadow: ParsedBoxShadow = { @@ -110,6 +149,13 @@ export default function processBoxShadow( return result; } +function cloneBoxShadows( + boxShadows: ReadonlyArray, +): Array { + // Style processing callers can mutate the returned array and its entries. + return boxShadows.map(boxShadow => ({...boxShadow})); +} + function parseBoxShadowString(rawBoxShadows: string): Array { let result: Array = [];