Skip to content

perf(css-processor): cache compiled inline CSS strings#39

Open
pataar wants to merge 4 commits into
native-html:mainfrom
pataar:perf/inline-css-cache
Open

perf(css-processor): cache compiled inline CSS strings#39
pataar wants to merge 4 commits into
native-html:mainfrom
pataar:perf/inline-css-cache

Conversation

@pataar
Copy link
Copy Markdown

@pataar pataar commented May 10, 2026

Memoize CSSProcessor.compileInlineCSS results in a bounded LRU (256 entries). The same style="..." string returns the same CSSProcessedProps — skipping parse + validate on every repeat.

Safe: the compiled CSSProcessedProps is only mutated during CSSInlineParseRun.exec(); nothing downstream writes to it (merge() always returns a fresh instance).

Benchmarks

benchmark.js against TRenderEngine.buildTTree() end-to-end (full hoist + whitespace-collapse). 3 runs per workload, mean reported, RME <1%.

Workload Before After Δ
rnImageDoc translate-only 6.658 ms 6.560 ms −1.5% (noise)
rnImageDoc full pipeline 10.355 ms 10.317 ms −0.4% (noise)
cssHeavy(200) full pipeline 12.849 ms 6.760 ms −47.4% (≈1.9×)

Fixtures:

  • rnImageDoc — the existing 65 KB Docusaurus snippet in packages/performance-testing/html-sources.js. Diverse inline styles.
  • cssHeavy(200) — synthetic 200-element doc where <div>/<span>/<p> carry repeated design-system-shaped inline styles (mixed px/em/%, colors, spacing). Models the repetition the cache targets.

The diverse-content case is neutral; the win shows up wherever inline styles repeat — common in production.

Test plan

  • yarn test:css-processor — 1414 pass
  • yarn test:transient-render-engine — 191 + 66 snapshots pass
  • yarn test:render — 262 pass
  • TypeScript + ESLint clean
  • yarn test:transient-render-engine:perf passes thresholds

pataar added 2 commits May 10, 2026 20:26
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.
@5ZYSZ3K
Copy link
Copy Markdown
Collaborator

5ZYSZ3K commented May 10, 2026

Hi, thanks for your contribution!
I'll take a look at that tomorrow

@pataar
Copy link
Copy Markdown
Author

pataar commented May 10, 2026

All good. Thanks and no hurries

if (cache.size >= INLINE_CSS_CACHE_LIMIT) {
// Evict oldest (Map preserves insertion order).
const oldest = cache.keys().next().value;
if (oldest !== undefined) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this check protect us from? undefined is fine as a map key

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2026-05-11 at 22 00 37

TypeScript says no 😁

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could force it, but the check is cheap i guess

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, let's keep it that way, I just checked the behaviour in a JS environment and it sticked out

Comment thread packages/css-processor/src/CSSProcessor.ts
Comment thread packages/css-processor/src/CSSProcessor.ts Outdated
Comment thread packages/css-processor/src/CSSProcessor.ts Outdated
pataar added 2 commits May 11, 2026 21:56
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).
`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.
@pataar
Copy link
Copy Markdown
Author

pataar commented May 11, 2026

Good feedback. LRUCache can now be created through a factory. Added some tests for it as well.

@pataar pataar requested review from 5ZYSZ3K and jsamr May 11, 2026 20:02
Comment thread packages/css-processor/src/lruCache.ts
@codecov
Copy link
Copy Markdown

codecov Bot commented May 13, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.67%. Comparing base (327adbb) to head (cf7ddef).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #39      +/-   ##
==========================================
+ Coverage   98.65%   98.67%   +0.01%     
==========================================
  Files         140      141       +1     
  Lines        2085     2111      +26     
  Branches      639      644       +5     
==========================================
+ Hits         2057     2083      +26     
  Misses         27       27              
  Partials        1        1              
Flag Coverage Δ
css-processor 100.00% <100.00%> (ø)
render 97.85% <ø> (ø)
transient-render-engine 98.95% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants