diff --git a/packages/css-processor/src/CSSProcessor.ts b/packages/css-processor/src/CSSProcessor.ts index 80c2f4b4..1dc54e88 100644 --- a/packages/css-processor/src/CSSProcessor.ts +++ b/packages/css-processor/src/CSSProcessor.ts @@ -4,6 +4,7 @@ import { CSSNativeParseRun } from './CSSNativeParseRun'; import { CSSProcessedProps } from './CSSProcessedProps'; import { CSSPropertiesValidationRegistry } from './CSSPropertiesValidationRegistry'; import { defaultCSSProcessorConfig } from './default'; +import { createLRUCache, LRUCache } from './lruCache'; import { ExtraNativeShortStyle, ExtraNativeTextStyle, @@ -102,12 +103,18 @@ export type MixedStyleDeclaration = Omit< export class CSSProcessor { public readonly registry: CSSPropertiesValidationRegistry; + private readonly inlineCssCache: LRUCache | null; constructor(userConfig?: Partial) { const config = { ...defaultCSSProcessorConfig, ...userConfig }; this.registry = new CSSPropertiesValidationRegistry(config); + this.inlineCssCache = config.enableExperimentalCssLRUCache + ? createLRUCache( + config.maxCssLruCacheSize ?? 256 + ) + : null; } /** @@ -126,7 +133,16 @@ export class CSSProcessor { } compileInlineCSS(inlineCSS: string): CSSProcessedProps { + const cache = this.inlineCssCache; + if (cache) { + const cached = cache.get(inlineCSS); + if (cached !== undefined) { + return cached; + } + } const parseRun = new CSSInlineParseRun(inlineCSS, this.registry); - return parseRun.exec(); + const result = parseRun.exec(); + cache?.set(inlineCSS, result); + return result; } } diff --git a/packages/css-processor/src/__tests__/CSSProcessor.test.ts b/packages/css-processor/src/__tests__/CSSProcessor.test.ts index de85ed38..e641643f 100644 --- a/packages/css-processor/src/__tests__/CSSProcessor.test.ts +++ b/packages/css-processor/src/__tests__/CSSProcessor.test.ts @@ -134,8 +134,8 @@ function testSpecs(examples: Record) { outProp !== undefined ? outProp : outValue != null - ? { [key]: outValue } - : null; + ? { [key]: outValue } + : null; it(`compileInlineCSS method should ${ expectedValue === null ? 'ignore' : 'register' } "${paramCase(key)}" ${ @@ -160,8 +160,8 @@ function testSpecs(examples: Record) { outProp !== undefined ? outProp : outValue != null - ? { [key]: outValue } - : null; + ? { [key]: outValue } + : null; it(`compileStyleDeclaration should ${ expectedValue === null ? 'ignore' : 'register' } "${key}" ${ @@ -1145,3 +1145,44 @@ describe('CSSProcessor', () => { testSpecs(examples); }); }); + +describe('CSSProcessor inline-CSS LRU cache', () => { + it('does not cache by default (flag off)', () => { + const proc = new CSSProcessor(); + const a = proc.compileInlineCSS('color:red'); + const b = proc.compileInlineCSS('color:red'); + expect(a).not.toBe(b); + }); + + it('returns the same instance for identical strings when enabled', () => { + const proc = new CSSProcessor({ enableExperimentalCssLRUCache: true }); + const a = proc.compileInlineCSS('color:red'); + const b = proc.compileInlineCSS('color:red'); + expect(a).toBe(b); + }); + + it('evicts the least-recently-used entry once the cache is full', () => { + const proc = new CSSProcessor({ + enableExperimentalCssLRUCache: true, + maxCssLruCacheSize: 2 + }); + const first = proc.compileInlineCSS('color:red'); + proc.compileInlineCSS('color:blue'); + proc.compileInlineCSS('color:green'); // evicts 'color:red' + const firstAgain = proc.compileInlineCSS('color:red'); + expect(firstAgain).not.toBe(first); + }); + + it('keeps a touched entry alive after eviction pressure', () => { + const proc = new CSSProcessor({ + enableExperimentalCssLRUCache: true, + maxCssLruCacheSize: 2 + }); + const first = proc.compileInlineCSS('color:red'); + proc.compileInlineCSS('color:blue'); + proc.compileInlineCSS('color:red'); // touch — moves 'red' to most-recent + proc.compileInlineCSS('color:green'); // evicts 'color:blue', not 'color:red' + const firstAgain = proc.compileInlineCSS('color:red'); + expect(firstAgain).toBe(first); + }); +}); diff --git a/packages/css-processor/src/__tests__/lruCache.test.ts b/packages/css-processor/src/__tests__/lruCache.test.ts new file mode 100644 index 00000000..6bfaa125 --- /dev/null +++ b/packages/css-processor/src/__tests__/lruCache.test.ts @@ -0,0 +1,52 @@ +import { createLRUCache } from '../lruCache'; + +describe('createLRUCache', () => { + it('returns undefined for a missing key', () => { + const cache = createLRUCache(8); + expect(cache.get('missing')).toBeUndefined(); + }); + + it('returns the value previously set under a key', () => { + const cache = createLRUCache(8); + cache.set('a', 1); + expect(cache.get('a')).toBe(1); + }); + + it('evicts the least-recently-used entry once maxSize is reached', () => { + const cache = createLRUCache(2); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); // evicts 'a' + expect(cache.get('a')).toBeUndefined(); + expect(cache.get('b')).toBe(2); + expect(cache.get('c')).toBe(3); + }); + + it('touches an entry on get so it is no longer the LRU', () => { + const cache = createLRUCache(2); + cache.set('a', 1); + cache.set('b', 2); + cache.get('a'); // touch 'a' — 'b' is now LRU + cache.set('c', 3); // evicts 'b' + expect(cache.get('a')).toBe(1); + expect(cache.get('b')).toBeUndefined(); + expect(cache.get('c')).toBe(3); + }); + + it('updates an existing key in place without evicting other entries', () => { + const cache = createLRUCache(2); + cache.set('a', 1); + cache.set('b', 2); + cache.set('b', 99); // replace — must not evict 'a' + expect(cache.get('a')).toBe(1); + expect(cache.get('b')).toBe(99); + }); + + it('evicts the previous entry on every new key when maxSize is 1', () => { + const cache = createLRUCache(1); + cache.set('a', 1); + cache.set('b', 2); + expect(cache.get('a')).toBeUndefined(); + expect(cache.get('b')).toBe(2); + }); +}); diff --git a/packages/css-processor/src/config.ts b/packages/css-processor/src/config.ts index cb23cb3d..f858029c 100644 --- a/packages/css-processor/src/config.ts +++ b/packages/css-processor/src/config.ts @@ -99,4 +99,14 @@ export interface CSSProcessorConfig { * Menlo), false otherwise. */ isFontSupported(fontName: string): boolean | string; + + /** + * Enable a per-instance LRU cache for compiled inline CSS strings. + * + * @experimental Disabled by default. Bounded by {@link CSSProcessorConfig.maxCssLruCacheSize}. + */ + readonly enableExperimentalCssLRUCache?: boolean; + + /** Max entries in the inline-CSS LRU cache. Defaults to 256. */ + readonly maxCssLruCacheSize?: number; } diff --git a/packages/css-processor/src/default.ts b/packages/css-processor/src/default.ts index 35fa22aa..fa307cf6 100644 --- a/packages/css-processor/src/default.ts +++ b/packages/css-processor/src/default.ts @@ -38,5 +38,6 @@ export const defaultCSSProcessorConfig: CSSProcessorConfig = { isFontSupported() { return true; }, - skipFontFamilyValidation: false + skipFontFamilyValidation: false, + enableExperimentalCssLRUCache: false }; diff --git a/packages/css-processor/src/lruCache.ts b/packages/css-processor/src/lruCache.ts new file mode 100644 index 00000000..d9f78d53 --- /dev/null +++ b/packages/css-processor/src/lruCache.ts @@ -0,0 +1,37 @@ +/** + * Bounded LRU cache: reads touch the key as most-recent, writes evict the + * oldest once `maxSize` is reached. Avoids re-parsing repeated inline CSS + * without unbounded memory growth. + */ +export interface LRUCache { + get(key: K): V | undefined; + set(key: K, value: V): void; +} + +export function createLRUCache( + maxSize: number +): LRUCache { + const map = new Map(); + return { + get(key) { + const value = map.get(key); + if (value !== undefined) { + // LRU touch: move to most-recent end. + map.delete(key); + map.set(key, value); + } + return value; + }, + set(key, value) { + // Only evict when adding a new key; replacing an existing key updates + // in place and must not free an unrelated slot. + if (map.size >= maxSize && !map.has(key)) { + const oldest = map.keys().next().value; + if (oldest !== undefined) { + map.delete(oldest); + } + } + map.set(key, value); + } + }; +}