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
18 changes: 17 additions & 1 deletion packages/css-processor/src/CSSProcessor.ts
Comment thread
pataar marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -102,12 +103,18 @@ export type MixedStyleDeclaration = Omit<

export class CSSProcessor {
public readonly registry: CSSPropertiesValidationRegistry;
private readonly inlineCssCache: LRUCache<string, CSSProcessedProps> | null;
constructor(userConfig?: Partial<CSSProcessorConfig>) {
const config = {
...defaultCSSProcessorConfig,
...userConfig
};
this.registry = new CSSPropertiesValidationRegistry(config);
this.inlineCssCache = config.enableExperimentalCssLRUCache
? createLRUCache<string, CSSProcessedProps>(
config.maxCssLruCacheSize ?? 256
)
: null;
}

/**
Expand All @@ -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;
}
}
49 changes: 45 additions & 4 deletions packages/css-processor/src/__tests__/CSSProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ function testSpecs(examples: Record<string, Specs>) {
outProp !== undefined
? outProp
: outValue != null
? { [key]: outValue }
: null;
? { [key]: outValue }
: null;
it(`compileInlineCSS method should ${
expectedValue === null ? 'ignore' : 'register'
} "${paramCase(key)}" ${
Expand All @@ -160,8 +160,8 @@ function testSpecs(examples: Record<string, Specs>) {
outProp !== undefined
? outProp
: outValue != null
? { [key]: outValue }
: null;
? { [key]: outValue }
: null;
it(`compileStyleDeclaration should ${
expectedValue === null ? 'ignore' : 'register'
} "${key}" ${
Expand Down Expand Up @@ -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);
});
});
52 changes: 52 additions & 0 deletions packages/css-processor/src/__tests__/lruCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createLRUCache } from '../lruCache';

describe('createLRUCache', () => {
it('returns undefined for a missing key', () => {
const cache = createLRUCache<string, number>(8);
expect(cache.get('missing')).toBeUndefined();
});

it('returns the value previously set under a key', () => {
const cache = createLRUCache<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(1);
cache.set('a', 1);
cache.set('b', 2);
expect(cache.get('a')).toBeUndefined();
expect(cache.get('b')).toBe(2);
});
});
10 changes: 10 additions & 0 deletions packages/css-processor/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
3 changes: 2 additions & 1 deletion packages/css-processor/src/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,6 @@ export const defaultCSSProcessorConfig: CSSProcessorConfig = {
isFontSupported() {
return true;
},
skipFontFamilyValidation: false
skipFontFamilyValidation: false,
enableExperimentalCssLRUCache: false
};
37 changes: 37 additions & 0 deletions packages/css-processor/src/lruCache.ts
Original file line number Diff line number Diff line change
@@ -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<K, V extends {}> {
get(key: K): V | undefined;
set(key: K, value: V): void;
}

export function createLRUCache<K, V extends {}>(
maxSize: number
): LRUCache<K, V> {
const map = new Map<K, V>();
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;
Comment thread
jsamr marked this conversation as resolved.
if (oldest !== undefined) {
map.delete(oldest);
}
}
map.set(key, value);
}
};
}
Loading