Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
);
Original file line number Diff line number Diff line change
@@ -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<BoxShadowValue> = [{offsetX: 1, offsetY: 2}];

expect(processBoxShadow(value)).toEqual([{offsetX: 1, offsetY: 2}]);
value[0].offsetX = 3;
expect(processBoxShadow(value)).toEqual([{offsetX: 3, offsetY: 2}]);
});
});
58 changes: 52 additions & 6 deletions packages/react-native/Libraries/StyleSheet/processBoxShadow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,18 +29,55 @@ export type ParsedBoxShadow = {
inset?: boolean,
};

const boxShadowCache: Map<string, false | Array<ParsedBoxShadow>> = new Map();

export default function processBoxShadow(
rawBoxShadows: ?(ReadonlyArray<BoxShadowValue> | string),
): Array<ParsedBoxShadow> {
const result: Array<ParsedBoxShadow> = [];
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<BoxShadowValue>,
): Array<ParsedBoxShadow> {
const result: Array<ParsedBoxShadow> = [];

for (const rawBoxShadow of boxShadowList) {
const parsedBoxShadow: ParsedBoxShadow = {
Expand Down Expand Up @@ -110,6 +149,13 @@ export default function processBoxShadow(
return result;
}

function cloneBoxShadows(
boxShadows: ReadonlyArray<ParsedBoxShadow>,
): Array<ParsedBoxShadow> {
// Style processing callers can mutate the returned array and its entries.
return boxShadows.map(boxShadow => ({...boxShadow}));
}

function parseBoxShadowString(rawBoxShadows: string): Array<BoxShadowValue> {
let result: Array<BoxShadowValue> = [];

Expand Down
Loading