Skip to content

Commit b48379e

Browse files
committed
Cache parsed box shadow strings
1 parent d07bd6f commit b48379e

3 files changed

Lines changed: 193 additions & 6 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
12+
13+
import processBoxShadow from '../processBoxShadow';
14+
import * as Fantom from '@react-native/fantom';
15+
16+
const REPEATED_BOX_SHADOW =
17+
'0 1px 2px rgba(0, 0, 0, 0.2), inset 0 0 0 1px #ffffff';
18+
19+
let uniqueInputOffset = 0;
20+
21+
function processRepeatedBoxShadows(count: number): void {
22+
for (let i = 0; i < count; i++) {
23+
processBoxShadow(REPEATED_BOX_SHADOW);
24+
}
25+
}
26+
27+
function processUniqueBoxShadows(count: number): void {
28+
const offset = uniqueInputOffset;
29+
uniqueInputOffset += count;
30+
for (let i = 0; i < count; i++) {
31+
processBoxShadow(`${(offset + i).toString()}px 1px 2px rgba(0, 0, 0, 0.2)`);
32+
}
33+
}
34+
35+
Fantom.unstable_benchmark
36+
.suite('processBoxShadow')
37+
.test.each(
38+
[100, 1000],
39+
count => `process the same string ${count.toString()} times`,
40+
processRepeatedBoxShadows,
41+
)
42+
.test.each(
43+
[100, 1000],
44+
count => `process ${count.toString()} unique strings`,
45+
processUniqueBoxShadows,
46+
);
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import type {BoxShadowValue} from '../StyleSheetTypes';
12+
13+
import processBoxShadow from '../processBoxShadow';
14+
import * as ProcessColor from '../processColor';
15+
16+
describe('processBoxShadow cache', () => {
17+
it('does not expose cached results to mutation', () => {
18+
const value = '10px 5px 2px 3px red, inset 1px 2px blue';
19+
const expectedResult = [
20+
{
21+
offsetX: 10,
22+
offsetY: 5,
23+
blurRadius: 2,
24+
spreadDistance: 3,
25+
color: ProcessColor.default('red'),
26+
},
27+
{
28+
offsetX: 1,
29+
offsetY: 2,
30+
color: ProcessColor.default('blue'),
31+
inset: true,
32+
},
33+
];
34+
const firstResult = processBoxShadow(value);
35+
36+
firstResult[0].offsetX = 100;
37+
firstResult.pop();
38+
39+
const secondResult = processBoxShadow(value);
40+
expect(secondResult).toEqual(expectedResult);
41+
expect(secondResult).not.toBe(firstResult);
42+
expect(secondResult[0]).not.toBe(firstResult[0]);
43+
44+
secondResult[0].offsetX = 200;
45+
secondResult.pop();
46+
47+
expect(processBoxShadow(value)).toEqual(expectedResult);
48+
});
49+
50+
it('does not expose cached invalid results to mutation', () => {
51+
const value = '1px invalid';
52+
const firstResult = processBoxShadow(value);
53+
54+
firstResult.push({offsetX: 1, offsetY: 2});
55+
56+
const secondResult = processBoxShadow(value);
57+
expect(secondResult).toEqual([]);
58+
secondResult.push({offsetX: 3, offsetY: 4});
59+
expect(processBoxShadow(value)).toEqual([]);
60+
});
61+
62+
it('evicts the least recently used string', () => {
63+
const retainedValue = '98765px 2px';
64+
const evictedValue = '98764px 2px';
65+
const processColorSpy = jest.spyOn(ProcessColor, 'default');
66+
67+
processBoxShadow(evictedValue);
68+
processBoxShadow(evictedValue);
69+
processBoxShadow(retainedValue);
70+
processBoxShadow(retainedValue);
71+
72+
for (let i = 0; i < 600; i++) {
73+
processBoxShadow(`${(100000 + i).toString()}px 2px`);
74+
}
75+
processBoxShadow(retainedValue);
76+
for (let i = 600; i < 1100; i++) {
77+
processBoxShadow(`${(100000 + i).toString()}px 2px`);
78+
}
79+
processColorSpy.mockClear();
80+
81+
processBoxShadow(retainedValue);
82+
expect(processColorSpy).not.toHaveBeenCalled();
83+
processBoxShadow(evictedValue);
84+
expect(processColorSpy).toHaveBeenCalled();
85+
processColorSpy.mockRestore();
86+
});
87+
88+
it('does not cache object inputs', () => {
89+
const value: Array<BoxShadowValue> = [{offsetX: 1, offsetY: 2}];
90+
91+
expect(processBoxShadow(value)).toEqual([{offsetX: 1, offsetY: 2}]);
92+
value[0].offsetX = 3;
93+
expect(processBoxShadow(value)).toEqual([{offsetX: 3, offsetY: 2}]);
94+
});
95+
});

packages/react-native/Libraries/StyleSheet/processBoxShadow.js

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const COMMA_SPLIT_REGEX = /,(?![^()]*\))/;
1717
const WHITESPACE_SPLIT_REGEX = /\s+(?![^(]*\))/;
1818
const LENGTH_PARSE_REGEX = /^([+-]?\d*\.?\d+)(px)?$/;
1919
const NEWLINE_REGEX = /\n/g;
20+
const MAX_CACHE_SIZE = 1024;
21+
const CACHE_CANDIDATE: false = false;
2022

2123
export type ParsedBoxShadow = {
2224
offsetX: number,
@@ -27,18 +29,55 @@ export type ParsedBoxShadow = {
2729
inset?: boolean,
2830
};
2931

32+
const boxShadowCache: Map<string, false | Array<ParsedBoxShadow>> = new Map();
33+
3034
export default function processBoxShadow(
3135
rawBoxShadows: ?(ReadonlyArray<BoxShadowValue> | string),
3236
): Array<ParsedBoxShadow> {
33-
const result: Array<ParsedBoxShadow> = [];
3437
if (rawBoxShadows == null) {
35-
return result;
38+
return [];
39+
}
40+
41+
if (typeof rawBoxShadows !== 'string') {
42+
return processBoxShadowList(rawBoxShadows);
43+
}
44+
45+
const cachedResult = boxShadowCache.get(rawBoxShadows);
46+
if (cachedResult != null && cachedResult !== CACHE_CANDIDATE) {
47+
// Map iteration order follows insertion order. Re-inserting on a hit keeps
48+
// the least-recently-used entry first.
49+
boxShadowCache.delete(rawBoxShadows);
50+
boxShadowCache.set(rawBoxShadows, cachedResult);
51+
return cloneBoxShadows(cachedResult);
3652
}
3753

38-
const boxShadowList =
39-
typeof rawBoxShadows === 'string'
40-
? parseBoxShadowString(rawBoxShadows.replace(NEWLINE_REGEX, ' '))
41-
: rawBoxShadows;
54+
// Wait for a repeat before copying a parsed value into the cache. This keeps
55+
// one-off strings from paying the cost of cloning the result.
56+
const wasSeen = cachedResult === CACHE_CANDIDATE;
57+
const result = processBoxShadowList(
58+
parseBoxShadowString(rawBoxShadows.replace(NEWLINE_REGEX, ' ')),
59+
);
60+
61+
if (wasSeen) {
62+
boxShadowCache.delete(rawBoxShadows);
63+
boxShadowCache.set(rawBoxShadows, cloneBoxShadows(result));
64+
} else {
65+
if (boxShadowCache.size >= MAX_CACHE_SIZE) {
66+
const oldestKey = boxShadowCache.keys().next().value;
67+
if (oldestKey != null) {
68+
boxShadowCache.delete(oldestKey);
69+
}
70+
}
71+
boxShadowCache.set(rawBoxShadows, CACHE_CANDIDATE);
72+
}
73+
74+
return result;
75+
}
76+
77+
function processBoxShadowList(
78+
boxShadowList: ReadonlyArray<BoxShadowValue>,
79+
): Array<ParsedBoxShadow> {
80+
const result: Array<ParsedBoxShadow> = [];
4281

4382
for (const rawBoxShadow of boxShadowList) {
4483
const parsedBoxShadow: ParsedBoxShadow = {
@@ -110,6 +149,13 @@ export default function processBoxShadow(
110149
return result;
111150
}
112151

152+
function cloneBoxShadows(
153+
boxShadows: ReadonlyArray<ParsedBoxShadow>,
154+
): Array<ParsedBoxShadow> {
155+
// Style processing callers can mutate the returned array and its entries.
156+
return boxShadows.map(boxShadow => ({...boxShadow}));
157+
}
158+
113159
function parseBoxShadowString(rawBoxShadows: string): Array<BoxShadowValue> {
114160
let result: Array<BoxShadowValue> = [];
115161

0 commit comments

Comments
 (0)