From 70627fb796381b35ac69232ed0212a64a474de12 Mon Sep 17 00:00:00 2001 From: pataar Date: Sun, 10 May 2026 20:26:23 +0200 Subject: [PATCH 1/4] perf(css-processor): cache compiled inline CSS strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memoize `CSSProcessor.compileInlineCSS` results with a bounded LRU (256 entries). Inline `style="..."` strings repeat heavily in real-world HTML (design-system markup, syntax-highlighted code spans, table cells), and recompiling the same string for each occurrence is pure waste — the output `CSSProcessedProps` is immutable post-construction. --- packages/css-processor/src/CSSProcessor.ts | 29 +++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/css-processor/src/CSSProcessor.ts b/packages/css-processor/src/CSSProcessor.ts index 80c2f4b4..0defdc65 100644 --- a/packages/css-processor/src/CSSProcessor.ts +++ b/packages/css-processor/src/CSSProcessor.ts @@ -100,8 +100,18 @@ export type MixedStyleDeclaration = Omit< [k in MixedSizeCSSPropertiesKeys]?: number | string; }; +// Bounded to avoid unbounded growth on long-lived processors. Sized to comfortably +// cover docs with many distinct inline-style strings while staying small enough +// that lookups in the underlying Map remain fast. +const INLINE_CSS_CACHE_LIMIT = 256; + export class CSSProcessor { public readonly registry: CSSPropertiesValidationRegistry; + // LRU cache for compiled inline CSS strings. The same string commonly repeats + // across many elements (think syntax-highlighted code spans or design-system + // styling), so caching the compiled result avoids the parse + validate + + // CSSProcessedProps construction work on each repeat. + private inlineCssCache: Map = new Map(); constructor(userConfig?: Partial) { const config = { ...defaultCSSProcessorConfig, @@ -126,7 +136,24 @@ export class CSSProcessor { } compileInlineCSS(inlineCSS: string): CSSProcessedProps { + const cache = this.inlineCssCache; + const cached = cache.get(inlineCSS); + if (cached !== undefined) { + // LRU touch: re-insert to mark as most recently used. + cache.delete(inlineCSS); + cache.set(inlineCSS, cached); + return cached; + } const parseRun = new CSSInlineParseRun(inlineCSS, this.registry); - return parseRun.exec(); + const result = parseRun.exec(); + if (cache.size >= INLINE_CSS_CACHE_LIMIT) { + // Evict oldest entry (Map preserves insertion order). + const oldest = cache.keys().next().value; + if (oldest !== undefined) { + cache.delete(oldest); + } + } + cache.set(inlineCSS, result); + return result; } } From dfafac184ff7d4d2580ce3da5999d34c71a2812d Mon Sep 17 00:00:00 2001 From: pataar Date: Sun, 10 May 2026 20:29:13 +0200 Subject: [PATCH 2/4] style(css-processor): compact cache comments --- packages/css-processor/src/CSSProcessor.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/css-processor/src/CSSProcessor.ts b/packages/css-processor/src/CSSProcessor.ts index 0defdc65..b6c8626d 100644 --- a/packages/css-processor/src/CSSProcessor.ts +++ b/packages/css-processor/src/CSSProcessor.ts @@ -100,17 +100,12 @@ export type MixedStyleDeclaration = Omit< [k in MixedSizeCSSPropertiesKeys]?: number | string; }; -// Bounded to avoid unbounded growth on long-lived processors. Sized to comfortably -// cover docs with many distinct inline-style strings while staying small enough -// that lookups in the underlying Map remain fast. +// Bounded to avoid unbounded growth on long-lived processors. const INLINE_CSS_CACHE_LIMIT = 256; export class CSSProcessor { public readonly registry: CSSPropertiesValidationRegistry; - // LRU cache for compiled inline CSS strings. The same string commonly repeats - // across many elements (think syntax-highlighted code spans or design-system - // styling), so caching the compiled result avoids the parse + validate + - // CSSProcessedProps construction work on each repeat. + // LRU cache: same inline string compiles to the same CSSProcessedProps. private inlineCssCache: Map = new Map(); constructor(userConfig?: Partial) { const config = { @@ -147,7 +142,7 @@ export class CSSProcessor { const parseRun = new CSSInlineParseRun(inlineCSS, this.registry); const result = parseRun.exec(); if (cache.size >= INLINE_CSS_CACHE_LIMIT) { - // Evict oldest entry (Map preserves insertion order). + // Evict oldest (Map preserves insertion order). const oldest = cache.keys().next().value; if (oldest !== undefined) { cache.delete(oldest); From 7c7286113c1fda4267b4a675ff93d6ae9b4a1ec7 Mon Sep 17 00:00:00 2001 From: pataar Date: Mon, 11 May 2026 21:56:32 +0200 Subject: [PATCH 3/4] refactor(css-processor): gate inline-CSS cache behind opt-in flag Address PR review feedback: - Move LRU logic out of `CSSProcessor` into a generic `createLRUCache` closure factory in `lruCache.ts`. - Gate the cache behind `CSSProcessorConfig.enableExperimentalCssLRUCache` (default `false`) so it stays opt-in given the memory trade-off. - Add `CSSProcessorConfig.maxCssLruCacheSize` (default 256) for tuning. - Drop the redundant `oldest !== undefined` guard left over from the previous implementation; `Map.delete(undefined)` is harmless anyway. - Cover the new behavior with tests (default off, flag on, eviction, touch-keeps-alive). --- packages/css-processor/src/CSSProcessor.ts | 32 +++++------- .../src/__tests__/CSSProcessor.test.ts | 49 +++++++++++++++++-- packages/css-processor/src/config.ts | 10 ++++ packages/css-processor/src/default.ts | 3 +- packages/css-processor/src/lruCache.ts | 35 +++++++++++++ 5 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 packages/css-processor/src/lruCache.ts diff --git a/packages/css-processor/src/CSSProcessor.ts b/packages/css-processor/src/CSSProcessor.ts index b6c8626d..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, @@ -100,19 +101,20 @@ export type MixedStyleDeclaration = Omit< [k in MixedSizeCSSPropertiesKeys]?: number | string; }; -// Bounded to avoid unbounded growth on long-lived processors. -const INLINE_CSS_CACHE_LIMIT = 256; - export class CSSProcessor { public readonly registry: CSSPropertiesValidationRegistry; - // LRU cache: same inline string compiles to the same CSSProcessedProps. - private inlineCssCache: Map = new Map(); + 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; } /** @@ -132,23 +134,15 @@ export class CSSProcessor { compileInlineCSS(inlineCSS: string): CSSProcessedProps { const cache = this.inlineCssCache; - const cached = cache.get(inlineCSS); - if (cached !== undefined) { - // LRU touch: re-insert to mark as most recently used. - cache.delete(inlineCSS); - cache.set(inlineCSS, cached); - return cached; + if (cache) { + const cached = cache.get(inlineCSS); + if (cached !== undefined) { + return cached; + } } const parseRun = new CSSInlineParseRun(inlineCSS, this.registry); const result = parseRun.exec(); - if (cache.size >= INLINE_CSS_CACHE_LIMIT) { - // Evict oldest (Map preserves insertion order). - const oldest = cache.keys().next().value; - if (oldest !== undefined) { - cache.delete(oldest); - } - } - cache.set(inlineCSS, result); + 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/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..c280d746 --- /dev/null +++ b/packages/css-processor/src/lruCache.ts @@ -0,0 +1,35 @@ +/** + * 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) { + if (map.size >= maxSize) { + const oldest = map.keys().next().value; + if (oldest !== undefined) { + map.delete(oldest); + } + } + map.set(key, value); + } + }; +} From cf7ddef9e561052123581b985cf5469342fcd0b5 Mon Sep 17 00:00:00 2001 From: pataar Date: Mon, 11 May 2026 21:59:34 +0200 Subject: [PATCH 4/4] fix(css-processor): don't evict LRU when replacing an existing key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `set` was evicting the oldest entry whenever `map.size >= maxSize`, even if the key being written already existed in the map. Replacing an entry updates in place — it doesn't free a slot — so the eviction was dropping unrelated data. Skip the eviction when `map.has(key)` is true and add a dedicated `lruCache.test.ts` covering the contract, including a regression case for the replace-doesn't-evict invariant. --- .../src/__tests__/lruCache.test.ts | 52 +++++++++++++++++++ packages/css-processor/src/lruCache.ts | 4 +- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 packages/css-processor/src/__tests__/lruCache.test.ts 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/lruCache.ts b/packages/css-processor/src/lruCache.ts index c280d746..d9f78d53 100644 --- a/packages/css-processor/src/lruCache.ts +++ b/packages/css-processor/src/lruCache.ts @@ -23,7 +23,9 @@ export function createLRUCache( return value; }, set(key, value) { - if (map.size >= maxSize) { + // 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);