Skip to content

Conversation

@asamuzaK
Copy link
Contributor

@asamuzaK asamuzaK commented Dec 27, 2025

Summary
Adds an LRU cache to reduce repeated css-tree parsing/validation and improve performance of CSS parsing utilities.

Part of jsdom/jsdom#3985

What this PR does

  • Adds lib/utils/cache.js: simple LRU wrapper using lru-cache (max: 4096) with a null sentinel for null values.
  • Caches results in lib/parsers.js for:
    • isValidPropertyValue (key: isValidPropertyValue_${prop}_${val}) — caches boolean results
    • resolveCalc (key: resolveCalc_${val}) — caches resolved string value
    • parsePropertyValue (key: parsePropertyValue_${prop}_${val}_${caseSensitive}) — caches parsed AST/plain-object results or false when invalid
  • Adds lru-cache to package.json and package-lock.json.

Benchmarks
cssstyle main:

> jsdom@27.4.0 benchmark
> node ./benchmark/runner --suites style

# style/createElement + setAttribute('style') #
jsdom  x 44.33 ops/sec ±1.27% (59 runs sampled)

# style/createElement + style properties #
jsdom  x 142 ops/sec ±3.34% (72 runs sampled)

# style/createElement + style.cssText #
jsdom  x 36.25 ops/sec ±2.77% (59 runs sampled)

# style/innerHTML: divs with inline styles #
jsdom  x 41.36 ops/sec ±2.46% (55 runs sampled)

# style/innerHTML: simple divs (no styles) #
jsdom  x 3,739 ops/sec ±1.71% (88 runs sampled)

cssstyle PR:

> jsdom@27.4.0 benchmark
> node ./benchmark/runner --suites style

# style/createElement + setAttribute('style') #
jsdom  x 178 ops/sec ±1.46% (83 runs sampled)

# style/createElement + style properties #
jsdom  x 248 ops/sec ±3.14% (78 runs sampled)

# style/createElement + style.cssText #
jsdom  x 148 ops/sec ±3.85% (76 runs sampled)

# style/innerHTML: divs with inline styles #
jsdom  x 154 ops/sec ±2.61% (79 runs sampled)

# style/innerHTML: simple divs (no styles) #
jsdom  x 3,837 ops/sec ±2.53% (89 runs sampled)

max: 4096
});

// A sentinel symbol used to store null values, as lru-cache does not support nulls.
Copy link
Member

Choose a reason for hiding this comment

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

I don't think this is actually necessary. Test:

"use strict";
const { LRUCache } = require("lru-cache");

const cache = new LRUCache({ max: 4096 });
cache.set("abc", null);

console.log(cache.get("abc"));
console.log(cache.get("def"));

prints null, then undefined.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

7e8a308

Removed null sentinel.

Copy link
Member

Choose a reason for hiding this comment

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

Can you just remove the entire cache.js file and create a new LRUCache at the top of parsers.js instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

88f76d8

benchmark:

# style/createElement + setAttribute('style') #
jsdom  x 172 ops/sec ±1.48% (81 runs sampled)

# style/createElement + style properties #
jsdom  x 228 ops/sec ±3.03% (73 runs sampled)

# style/createElement + style.cssText #
jsdom  x 154 ops/sec ±3.24% (78 runs sampled)

# style/innerHTML: divs with inline styles #
jsdom  x 144 ops/sec ±3.02% (74 runs sampled)

# style/innerHTML: simple divs (no styles) #
jsdom  x 4,028 ops/sec ±3.59% (89 runs sampled)

Introduced a new cache utility in lib/utils/cache.js using lru-cache to store up to 4096 items, with support for caching null values. Added lru-cache as a dependency in package.json.
Introduces caching for isValidPropertyValue, resolveCalc, and parsePropertyValue to improve performance by avoiding redundant computations. Utilizes getCache and setCache from utils/cache to store and retrieve results based on input parameters.
Rearranged the order of exported functions in module.exports for better organization and consistency. No functional changes were made.
benchmark result:
```
> jsdom@27.4.0 benchmark
> node ./benchmark/runner --suites style

# style/createElement + setAttribute('style') #
jsdom  x 170 ops/sec ±1.30% (81 runs sampled)

# style/createElement + style properties #
jsdom  x 246 ops/sec ±3.05% (80 runs sampled)

# style/createElement + style.cssText #
jsdom  x 141 ops/sec ±2.70% (79 runs sampled)

# style/innerHTML: divs with inline styles #
jsdom  x 154 ops/sec ±2.26% (79 runs sampled)

# style/innerHTML: simple divs (no styles) #
jsdom  x 3,756 ops/sec ±2.11% (87 runs sampled)
```
@asamuzaK
Copy link
Contributor Author

Once this PR is merged, please release a new cssstyle and jsdom.
After that, let's reach out to the guys at jsdom/jsdom#3985 to see if the actual performance issue still exists (still getting timeout errors?).

try {
const ast = parseCSS(val, {
context: "value"
});
Copy link
Member

Choose a reason for hiding this comment

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

Sorry, I just noticed. Why does this use { context: "value" } but the other one use { options: { context: "value" } }? Is there a hidden bug here?

(I was wondering if we might get some performance by caching parseCSS() results, i.e., sharing the cache between parsePropertyValue and isValidPropertyValue. But, that could be a followup anyway, so it is not blocking.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

1b2c0b8

Thanks for the catch, was a typo...
{ context: "value" } is the correct option for csstree.

Copy link
Member

Choose a reason for hiding this comment

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

I worry a bit that our test coverage didn't catch this, but oh well...

@domenic domenic merged commit 2818a50 into jsdom:main Dec 31, 2025
3 checks passed
@asamuzaK
Copy link
Contributor Author

asamuzaK commented Dec 31, 2025

FYI

I was wondering if we might get some performance by caching parseCSS() results

For example, if you apply caching to parseCSS:

const parseCSS = (val, opt, toObject = false) => {
  if (typeof val !== "string") {
    val = prepareValue(val);
  }
  const cacheKey = `parseCSS_${val}_${opt?.context}_${toObject}`;
  const cachedValue = lruCache.get(cacheKey);
  if (cachedValue) {
    return cachedValue;
  }
  let result;
  const ast = cssTree.parse(val, opt);
  if (toObject) {
    result = cssTree.toPlainObject(ast);
  } else {
    result = ast;
  }
  lruCache.set(cacheKey, result);
  return result;
};

benchmark:

> jsdom@27.4.0 benchmark
> node ./benchmark/runner --suites style

# style/createElement + setAttribute('style') #
jsdom  x 183 ops/sec ±1.20% (84 runs sampled)

# style/createElement + style properties #
jsdom  x 236 ops/sec ±2.96% (81 runs sampled)

# style/createElement + style.cssText #
jsdom  x 159 ops/sec ±3.12% (82 runs sampled)

# style/innerHTML: divs with inline styles #
jsdom  x 161 ops/sec ±2.69% (76 runs sampled)

# style/innerHTML: simple divs (no styles) #
jsdom  x 3,868 ops/sec ±2.71% (91 runs sampled)

There's a slight improvement.
Shall I submit another PR?

@asamuzaK asamuzaK deleted the cache branch December 31, 2025 09:34
@domenic
Copy link
Member

domenic commented Dec 31, 2025

I think we should only do it if we can eliminate the separate parsePropertyValue and isValidPropertyValue caches at the same time, and still get good results. (Even if they are slightly less good than the current version.)

One thing that can be difficult about this sort of work is that by running a single benchmark that runs the same code over and over, we are hitting the best case for a LRU cache. But in the real world, people might be running code that fills up some of the cache with parsePropertyValue results, and then some of it with isValidPropertyValue results, in a way that causes the parsePropertyValue results to get evicted.

So as a general principle, if we can reduce the amount of competition for cache entries, that would likely be a good direction. (That is, we would move from 3 types of cache entries: isValidPropertyValue, parsePropertyValue, and resolveCalc, to 2 types: parseCSS and resolveCalc.)

@asamuzaK
Copy link
Contributor Author

Well then, let's wait and see.
良いお年を!

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.

2 participants