Skip to content

Replace CSS parsing, selectors, and computed styles - #4417

Open
domenic wants to merge 6 commits into
mainfrom
codex/css-replacement-squashed
Open

domenic wants to merge 6 commits into
mainfrom
codex/css-replacement-squashed

Conversation

@domenic

@domenic domenic commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

jsdom's CSS implementation (including selectors) has historically been its weakest link, with new issues opened against it constantly.

Over the years many people have stepped up to help, either directly through jsdom contributions, or through maintaining third-party packages that jsdom came to depend on. These include @dperini's nwmatcher / nwsapi, @NV's CSSOM and then @acemir's replacement fork, and cssstyle maintained by first @chad3814, then @jsakas, and finally by the jsdom organization itself. In recent years a lot of work has been done by @asamuzaK, with their domSelector and cssColor packages, as well as with their many, many contributions directly to jsdom and to cssstyle.

However, the mishmash of packages was never a great solution. It balanced the time constraints of the jsdom project (i.e., me) against good architecture: by letting others maintain these parts of jsdom, I could offload some of the maintenance responsibilities to others. This has historically been a reasonable trade for me, but never great for the users of jsdom. With the advent of agentic coding, I started reversing this trend, e.g. moving cssstyle into jsdom in 4aaacc6.

The most heroic effort to improve jsdom's CSS support came from @cdoublev, who worked for many years to create the @cdoublev/css package by reimplementing ~all of the CSS specifications from scratch. (By hand, as far as I can tell!) In terms of correctness and completeness, the result was much better than the mishmash of historical jsdom code and third-party packages. It also uncovered a ton of mismatches between CSS specifications, tests, and implementations, which was itself a great contribution to the web platform.

In #4223, @cdoublev submitted an attempt at integrating his work into jsdom. Unfortunately, the result wasn't something we could accept without significant extra effort. Issues around performance were blocking, and it seemed like the integration with jsdom's internals would take a lot of careful work.

A couple months after that discussion, watching the performance and correctness PRs continue to pour in against jsdom's CSS implementation, I got fed up. Everything needed to be thrown out and replaced, to be at least as correct as @cdoublev/css. And really, if I were serious about CSS in jsdom, then it all needed to be integrated into jsdom itself. Web browsers aren't split into multiple npm packages, and neither should be jsdom.

So I spent 3 banked resets' worth of ChatGPT Astra 6 Ultra tokens, with @cdoublev/css as the starting point, with the goal to make it at least as fast as jsdom's main branch, but at least as correct as @cdoublev/css. The result is this pull request. It brings in all CSS functionality, including selectors, to the jsdom codebase, fixing tons of correctness bugs and implementing many new features along the way, while being much faster than main. Previously this would have been impossible for me to maintain. But I am confident that in the modern age, it will be no problem for me and my agents.

(Agents also made it possible for me to meet the standard I requested from @cdoublev in #4223, of producing web platform test PRs or spec clarification issues for every spec/implementation/test mismatch. See below for the full inventory. I'm excited about how this will improve the general CSS spec and test ecosystem for everyone.)

I am trepidatious about the contributor reaction to this, especially from recent active contributors like @asamuzaK and @cdoublev. I want to ensure you realize how valuable your work has been to jsdom. Although jsdom will not be depending on your packages, you've been crucial to getting jsdom to this point, and I would love your help continuing to improve it going forward. We're in a new world, where agentic efforts can allow us to write tons of code that's specification-compliant and fast. For me, this is exciting, and has renewed my interest in working on jsdom. I can understand and accept that you might feel otherwise.

Ultimately, I'm hopeful that we can all appreciate the great step forward that this sort of work is for users of jsdom, and work together on continuing to evolve this fun, quixotic, open-source Node.js reimplementation of a web browser.


Agent-created summary of how this work is based on `@cdoublev/css` and how it departs

The proposed jsdom CSS replacement began with a port of cdoublev/css. Its central design carries through: a grammar-driven parser produces structured values that feed shorthand expansion, serialization, selector matching, and computed styles. The largest changes concern how that engine executes and how its work survives between DOM operations.

A useful starting point is the value representation (cdoublev/css, jsdom). Values carry grammar types alongside their contents; lists carry separators; an explicit sentinel represents omitted components. This lets later stages distinguish values that happen to serialize alike but occupy different grammatical roles. jsdom uses ordinary arrays in place of the original List subclass, but retains this contract. The continuity is especially visible in background expansion (original, jsdom): distributing layers among longhands, supplying omitted components, and taking the color from the final layer. Much of the property-specific knowledge in shorthand expansion, serialization, and parser validation comes directly from this foundation.

The grammar parser shows a different kind of continuity. Compare the original transition table with jsdom’s execution loop. The latter replaces table interpretation with explicit control flow while preserving backtracking and the distinction between a candidate match and an accepted parse. That distinction matters when CSS-specific postprocessing rejects something the grammar initially matched. First-token analysis skips impossible alternatives; context-sensitive cases still take the full parsing path. The tokenizer likewise scans through the source string instead of first building a character array. These changes reduce execution and allocation overhead without changing the grammar model.

Object ownership is where the port starts to reshape the surrounding architecture. Upstream constructs a style declaration with each style rule; jsdom creates it when requested. Parsed syntax can be shared through bounded caches while each mutable CSSOM object retains independent state. This also permits deferring declaration validation and value construction. One subtle consequence is that URL context must be captured before those deferred reads: changing a document’s URL must not reinterpret an already-authored declaration when its value is finally inspected.

Computed styles extend that separation across multiple reads. cdoublev/css already resolves individual properties on demand, but its declaration collection rebuilds stylesheet traversal and matching contexts for each property. jsdom gives each element a persistent style state: candidate rules, matching results, and successive stages of property resolution. Reading color after display can reuse the cascade work without computing every other property. Internal focusability checks use that same resolver to read display without constructing a public computed-style object.

Layer ordering and property registrations have a longer lifetime than element styles. They survive DOM mutations while the media conditions used to collect them still match; those conditions are rechecked once per style generation. Stylesheet edits, attachment, and removal discard both caches. This avoids walking every stylesheet again just because an element’s class changed. Element invalidation remains document-wide, so rereading unrelated elements still rebuilds their styles.

Selectors benefit from access to the same DOM machinery. Both implementations narrow candidates using IDs, classes, and tags. jsdom’s query planner also considers selective ancestors: for #panel .item, it can restrict candidate matching to the panel’s subtree when that is cheaper than checking every .item. It works directly with jsdom’s implementation objects, indexes, and ordered collections. That access also matters for correctness. The public activeElement getter retargets focus across shadow boundaries; the matcher needs the actual focused element. Assigned slots, closed shadow roots, and custom-element upgrade state similarly belong to the DOM implementation and are available directly here.

The computed-value machinery has also grown beyond the imported implementation. Upstream’s computed-value path leaves arbitrary substitution unfinished. jsdom’s resolver handles substitution, dependency cycles, and registered custom properties within the per-element state. This is a useful place to trace how the inherited typed values acquire element-dependent meaning: parsing var(--color) is only the beginning of resolving it.

Finally, the build takes advantage of inputs jsdom controls. It runs the CSS parser over initial values and the default stylesheet, emitting reusable data without binding it to a particular window. Compare upstream’s configurable environment initialization with jsdom’s default-stylesheet compiler. The data-generation pipeline also combines Webref property metadata with patched CSSTree grammars. Those packages supply build-time data; the runtime parser remains the engine described above. This keeps grammar updates separate from the algorithms needed to implement the features they describe.


Upstream test and specification follow-ups

This work led to 34 WPT pull requests, eleven CSSWG issues, and one Compat issue, plus follow-up comments on existing CSSWG issues. The links include PRs that have already merged.

WPT pull requests

CSSWG issues

Compat issue

Follow-up comments on existing CSSWG issues


Full CSS and selector benchmark results, with construction and DOM workloads

Measured on October 25, 2026 against main at 7b741bb7 and jsdom v30.1.3 installed from npm, using this PR at 00e369a7. This covers all 148 tasks in the 20 suites below: every CSS and selector benchmark, the constructor suite, and DOM construction, HTML parsing, cloning, and focus. The original five workloads from #3985 are included separately. The release comparison includes improvements already present on main; the main comparison measures this PR’s changes.

How to read the results

Times are microseconds per complete benchmark operation (1,000 µs = 1 ms). A row that processes many nodes, rules, or queries reports the whole batch. Speedup is baseline time divided by PR time: above 1× is faster; below 1× is slower. These are synthetic API workloads. Small differences should not be treated as significant. There is no combined score across these differently sized workloads.

Method

  • Node.js 26.8.2 and Tinybench 6.2.0 on Linux/WSL2, with an AMD Ryzen 9 7950X3D. Runs were sequential and pinned to CPU 2.
  • Identical benchmark fixtures from the PR were used for all three implementations. Main and the PR used their lockfile dependencies; the release used a fresh npm installation. In particular, main used @asamuzakjp/dom-selector 10.0.3 and @asamuzakjp/css-color 7.1.3; the release resolved those dependencies to 10.0.5 and 7.1.9.
  • Each measured task/version ran in a separate process in each of three rounds, rotating version order and reversing task order in the second round. The tables show the median of the three per-run median latencies.
  • The project’s npm run benchmark runner was configured for 500 ms of warmup and 1,500 ms of measurement, with at least five measured iterations per task. The cold computed-style, shorthand, and declaration-reading suites instead used 10 warmup iterations and 100 measured iterations, with fixture rebuilding outside the timed region.
  • The Performance regression in jsdom@27 #3985 workloads used 100 warmup iterations and 500 measured iterations per round, retaining the original timed operations. That table reports the median of the three per-run means.

Comparability notes

  • N/A means the implementation failed the fixture’s correctness preflight. Both baselines accept an invalid selector in the Sizzle corpus, cannot resolve the custom-property substitution fixtures, and lack the combination of cascade layers, registered properties, and live computed styles required by the style-invalidation fixture. These rows have no baseline speedup. The unrelated invalid-selector preflight was isolated from the valid-selector timings.
  • The get-computed-style and get-computed-style-cold suites only obtain computed-style objects. The property-access, cascade, shorthand, and invalidation suites measure property reads as well.
  • Declaration-scaling timings exclude stylesheet replacement and measure the first reads of fresh declarations. Deferred parsing can move work into those reads; the stylesheet-scaling tables separately include parsing and serialization workloads.
  • Separate output checks matched the valid Sizzle corpus’s ordered results, complex-selector results, mutation-query results, CSS.supports() answers, and the DOM/property values in Performance regression in jsdom@27 #3985 across all three implementations.
  • Some serialization differs: the PR returns 0px for the computed margin/padding defaults where both baselines return 0, and uses a shorter computed background shorthand. The release also rearranges declarations in the fresh-assignment fixture. These timings measure the same API workloads, with those output differences.

The complex-selector closest() case had substantial run-to-run variation (v30.1.3: 7.46, 2.58, 2.68 µs; main: 2.65, 2.66, 2.66 µs; PR: 3.29, 1.53, 1.52 µs). Its table entry uses the same median-of-three rule as the other rows; the precise ratio is less reliable than the repeatable query benchmarks.

api/constructor

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
new JSDOM() defaults 2,068 2,137 2,239 0.92× 0.95×
new JSDOM() with many elements 8,993 8,759 7,759 1.16× 1.13×
new JSDOM() with nested blocks 170,280 165,550 120,255 1.42× 1.38×
new JSDOM() with text-heavy markup 62,994 64,607 54,924 1.15× 1.18×
new JSDOM() with nested XML blocks 112,546 112,396 117,477 0.96× 0.96×
new JSDOM() and close 2,064 2,162 2,271 0.91× 0.95×
new JSDOM() and close with runScripts: outside-only 2,389 2,698 2,997 0.80× 0.90×
new JSDOM() and first computed style in a fresh process 473,625 485,355 350,777 1.35× 1.38×

css-supports/supports

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
CSS.supports('color', 'red') [2 args] 2.820 2.850 0.380 7.42× 7.50×
CSS.supports('unknown-prop', 'value') [2 args - false] 0.080 0.080 0.220 0.36× 0.36×
CSS.supports('display: flex') [fast path] 3.000 2.970 15.98 0.19× 0.19×
CSS.supports('--my-custom: red') [custom prop] 1.130 1.130 16.36 0.07× 0.07×
CSS.supports('(display: grid) and (not (display: inline-grid))') 30.49 30.64 40.98 0.74× 0.75×
CSS.supports('color: ...') [cache miss simulation] 24.17 23.78 21.49 1.12× 1.11×
Real-world: Modernizr-like startup batch (9 queries) 35.13 35.78 199.1 0.18× 0.18×
Real-world: CSS-in-JS component list render (100 iterations) 924.6 939.6 99.74 9.27× 9.42×

dom/cloning

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
cloneNode: shallow element 1.070 1.050 0.500 2.14× 2.10×
cloneNode: 100-field subtree 741.5 740.0 561.4 1.32× 1.32×
cloneNode: 100-deep subtree 733.4 727.0 671.4 1.09× 1.08×
cloneNode: template contents 802.1 799.0 604.7 1.33× 1.32×
importNode: 100-field subtree 740.5 738.6 551.5 1.34× 1.34×
adoptNode: 100-field subtree round trip 33.73 34.00 33.44 1.01× 1.02×

dom/construction

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
createElement 0.970 0.970 0.480 2.02× 2.02×
createTextNode 0.280 0.260 0.270 1.04× 0.96×
createComment 0.270 0.270 0.270 1.00× 1.00×
createDocumentFragment 0.210 0.220 0.210 1.00× 1.05×
createNodeIterator 1.230 1.240 1.240 0.99× 1.00×
createEvent 0.930 0.920 0.930 1.00× 0.99×
createProcessingInstruction 0.290 0.290 0.300 0.97× 0.97×

dom/focus

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
focus() between form controls 498.1 526.8 232.0 2.15× 2.27×
focus() on already focused control 0.730 0.760 0.640 1.14× 1.19×

dom/inner-html

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
tables 826,055 878,235 581,410 1.42× 1.51×
text 22,381 21,926 21,676 1.03× 1.01×

selectors/complex-selectors

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
matches 2.640 2.630 1.430 1.85× 1.84×
closest 2.680 2.660 1.530 1.75× 1.74×
querySelector 12.15 11.91 4.430 2.74× 2.69×
querySelectorAll 49.04 48.04 17.16 2.86× 2.80×

selectors/mutation-scaling

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
cached nth-child query (100 siblings) 21.71 21.53 14.41 1.51× 1.49×
reorder and nth-child query (100 siblings) 31.08 31.98 27.48 1.13× 1.16×
class mutation and :has() query (100 siblings) 537.6 536.7 37.97 14.16× 14.14×
uncached attribute selector (100 siblings) 184.5 186.0 38.75 4.76× 4.80×
cached nth-child query (1000 siblings) 210.9 212.1 148.0 1.42× 1.43×
reorder and nth-child query (1000 siblings) 311.8 309.3 255.8 1.22× 1.21×
class mutation and :has() query (1000 siblings) 7,881 8,214 396.8 19.86× 20.70×
uncached attribute selector (1000 siblings) 1,807 1,783 247.5 7.30× 7.21×
cached nth-child query (10000 siblings) 2,334 2,354 1,623 1.44× 1.45×
reorder and nth-child query (10000 siblings) 4,801 5,278 4,123 1.16× 1.28×
class mutation and :has() query (10000 siblings) 47,410 46,164 6,867 6.90× 6.72×
uncached attribute selector (10000 siblings) 31,436 31,122 3,503 8.97× 8.88×
batched 100 mutations and structural matching 164.5 164.1 153.7 1.07× 1.07×
interleaved 100 mutations and structural matching 281.2 274.7 201.6 1.40× 1.36×

selectors/sizzle

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
querySelectorAll (valid selectors) 233,958 236,214 48,883 4.79× 4.83×
querySelectorAll (invalid selectors) N/A N/A 51.77 — —
querySelector (valid selectors) 338,016 341,064 24,505 13.79× 13.92×
querySelector (invalid selectors) N/A N/A 50.62 — —

style/cascade-scaling

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
attribute mutation and computed properties (16 rules) 142.5 141.8 26.85 5.31× 5.28×
cached computed properties (16 rules) 1.400 1.390 1.140 1.23× 1.22×
attribute mutation and computed properties (128 rules) 160.1 151.8 26.59 6.02× 5.71×
cached computed properties (128 rules) 1.440 1.400 1.140 1.26× 1.23×
attribute mutation and computed properties (1024 rules) 274.1 270.0 26.65 10.28× 10.13×
cached computed properties (1024 rules) 1.410 1.440 1.150 1.23× 1.25×
inherited color after ancestor mutation (10 levels) 297.1 298.0 50.60 5.87× 5.89×
inherited color after ancestor mutation (100 levels) 3,026 2,991 481.5 6.28× 6.21×
inherited color after ancestor mutation (500 levels) 37,054 36,704 2,696 13.75× 13.62×
fresh stylesheet with unmatched ancestor selectors (128 rules) 4,927 5,260 1,641 3.00× 3.21×
fresh stylesheet with matching ancestor selectors (128 rules) 5,080 5,137 1,817 2.80× 2.83×

style/custom-properties

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
Assign and read a new nested custom-property value 1.090 1.140 17.85 0.06× 0.06×
Build stylesheet and resolve custom substitutions (128 targets) N/A N/A 16,072 — —
Read cached custom substitutions (128 targets) N/A N/A 99.10 — —

style/declaration-scaling

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
read one property from a fresh rule (16 declarations) 2.175 3.095 7.040 0.31× 0.44×
read all properties from a fresh rule (16 declarations) 8.685 8.595 31.29 0.28× 0.27×
read one property from a fresh rule (128 declarations) 3.170 3.855 10.92 0.29× 0.35×
read all properties from a fresh rule (128 declarations) 45.31 48.82 157.7 0.29× 0.31×
read one property from a fresh rule (1024 declarations) 4.065 4.560 14.38 0.28× 0.32×
read all properties from a fresh rule (1024 declarations) 232.1 253.6 3,568 0.07× 0.07×

style/get-computed-style

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
flat (20 siblings) 10.04 9.790 6.780 1.48× 1.44×
deep (10-level nesting) 0.530 0.540 0.410 1.29× 1.32×
deep, all levels (10-level nesting) 4.840 4.950 3.390 1.43× 1.46×

style/get-computed-style-cold

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
flat (20 siblings) 1,871 1,838 13.68 136.80× 134.43×
deep (10-level nesting) 257.1 250.0 1.825 140.87× 136.97×
deep, all levels (10-level nesting) 1,111 1,085 8.695 127.79× 124.77×

style/get-computed-style-property-access

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
few properties (4) 2.060 2.220 1.660 1.24× 1.34×
without color or length context (4) 2.040 2.140 1.640 1.24× 1.30×
many properties (30) 7.770 8.860 6.600 1.18× 1.34×

style/get-computed-style-shorthands

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
inherited border longhand 252.6 336.2 144.7 1.75× 2.32×
inherited background longhand 256.7 344.4 147.1 1.75× 2.34×
cold shorthand reads 371.2 362.0 404.9 0.92× 0.89×

style/style-invalidation

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
reread cached styles (1 elements, 128 rules) N/A N/A 0.620 — —
mutate independent region and reread styles (1 elements, 128 rules) N/A N/A 73.50 — —
reread cached styles (32 elements, 128 rules) N/A N/A 12.47 — —
mutate independent region and reread styles (32 elements, 128 rules) N/A N/A 1,577 — —
reread cached styles (256 elements, 128 rules) N/A N/A 103.6 — —
mutate independent region and reread styles (256 elements, 128 rules) N/A N/A 16,924 — —
mutate independent region and reread styles (32 elements, 16 rules) N/A N/A 1,454 — —
mutate independent region and reread styles (32 elements, 1024 rules) N/A N/A 1,458 — —

style/style-property

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
innerHTML: simple divs (no styles) 108.3 106.6 94.82 1.14× 1.12×
innerHTML: divs with inline styles 3,014 423.6 140.3 21.48× 3.02×
createElement + style properties 342.6 303.3 268.2 1.28× 1.13×
createElement + setAttribute('style') 2,640 265.2 22.92 115.17× 11.57×
createElement + style.cssText 2,762 315.7 97.29 28.39× 3.24×
createElement + visually hidden input styles 603.6 487.6 559.3 1.08× 0.87×

style/stylesheet-scaling

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
replace stylesheet text (16 rules) 307.9 268.6 13.59 22.65× 19.77×
parse new stylesheet text (16 rules) 393.2 359.9 205.3 1.92× 1.75×
parse new stylesheet and compute style (16 rules) 834.7 806.2 308.2 2.71× 2.62×
parse new stylesheet and serialize declarations (16 rules) 452.5 543.4 610.0 0.74× 0.89×
serialize rule declarations (16 rules) 7.511 7.770 7.470 1.01× 1.04×
replace stylesheet text (128 rules) 2,432 2,066 43.48 55.94× 47.51×
parse new stylesheet text (128 rules) 3,087 2,771 1,455 2.12× 1.90×
parse new stylesheet and compute style (128 rules) 4,334 4,001 1,665 2.60× 2.40×
parse new stylesheet and serialize declarations (128 rules) 3,715 4,164 4,633 0.80× 0.90×
serialize rule declarations (128 rules) 57.87 59.54 58.77 0.98× 1.01×
replace stylesheet text (1024 rules) 22,892 20,288 301.5 75.93× 67.30×
parse new stylesheet text (1024 rules) 34,455 31,853 13,533 2.55× 2.35×
parse new stylesheet and compute style (1024 rules) 42,063 39,978 16,197 2.60× 2.47×
parse new stylesheet and serialize declarations (1024 rules) 38,510 43,357 41,069 0.94× 1.06×
serialize rule declarations (1024 rules) 490.0 496.8 474.5 1.03× 1.05×
parse ASCII stylesheet with custom properties (128 rules) 367.4 400.8 728.7 0.50× 0.55×
parse ASCII stylesheet and read custom properties (128 rules) 434.3 477.5 931.8 0.47× 0.51×
parse unmatched stylesheet with complex custom properties (128 rules) 5,128 5,328 1,179 4.35× 4.52×
parse CRLF stylesheet with custom properties (128 rules) 360.6 401.6 758.1 0.48× 0.53×
parse CRLF stylesheet and read custom properties (128 rules) 442.4 481.1 933.4 0.47× 0.52×
parse non-BMP stylesheet with custom properties (128 rules) 377.9 416.7 755.2 0.50× 0.55×
parse non-BMP stylesheet and read custom properties (128 rules) 451.4 499.1 948.5 0.48× 0.53×
parse ASCII stylesheet with custom properties (1024 rules) 2,870 3,233 6,051 0.47× 0.53×
parse ASCII stylesheet and read custom properties (1024 rules) 3,562 3,919 7,814 0.46× 0.50×
parse unmatched stylesheet with complex custom properties (1024 rules) 67,220 70,463 10,617 6.33× 6.64×
parse CRLF stylesheet with custom properties (1024 rules) 2,853 3,233 6,537 0.44× 0.49×
parse CRLF stylesheet and read custom properties (1024 rules) 3,434 3,886 8,292 0.41× 0.47×
parse non-BMP stylesheet with custom properties (1024 rules) 3,007 3,397 6,237 0.48× 0.54×
parse non-BMP stylesheet and read custom properties (1024 rules) 3,676 4,047 7,876 0.47× 0.51×
parse new plain media stylesheet (128 rules) 1,210 1,293 2,070 0.58× 0.62×
parse new range media stylesheet (128 rules) 1,373 1,463 2,369 0.58× 0.62×
parse new nested media stylesheet (128 rules) 1,261 1,336 2,051 0.61× 0.65×
parse new plain media stylesheet (512 rules) 5,144 5,415 9,540 0.54× 0.57×
parse new range media stylesheet (512 rules) 5,679 6,139 11,353 0.50× 0.54×
parse new nested media stylesheet (512 rules) 5,107 5,434 9,979 0.51× 0.54×
parse new plain media stylesheet (1024 rules) 11,586 15,084 19,219 0.60× 0.78×
parse new range media stylesheet (1024 rules) 12,961 24,367 22,841 0.57× 1.07×
parse new nested media stylesheet (1024 rules) 11,531 15,372 21,178 0.54× 0.73×
parse new page selector stylesheet (128 rules) 505.5 583.4 1,295 0.39× 0.45×
parse new page selector stylesheet (512 rules) 1,990 2,241 5,450 0.37× 0.41×
parse new page selector stylesheet (1024 rules) 3,940 4,507 12,110 0.33× 0.37×
parse stylesheet exceeding the syntax cache budget (16384 rules) 84,301 97,010 117,582 0.72× 0.83×

style/value-parsing

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
Parse a new width value 6.150 6.070 4.890 1.26× 1.24×
Parse a new color value 13.32 11.43 8.830 1.51× 1.29×
Parse a new background value 363.7 305.1 162.3 2.24× 1.88×
Parse a new box-shadow value 24.88 24.46 21.37 1.16× 1.14×
Assign a new declaration block 151.7 16.64 25.92 5.85× 0.64×

Original #3985 workloads

Task v30.1.3 (µs) main (µs) PR (µs) Speedup vs. v30.1.3 Speedup vs. main
innerHTML: 20 unstyled elements 164.5 161.0 144.3 1.14× 1.12×
innerHTML: 20 styled elements 2,845 436.7 185.2 15.36× 2.36×
20 elements with individual style assignments 398.9 367.9 275.0 1.45× 1.34×
20 elements with a style attribute 2,674 328.9 79.10 33.80× 4.16×
20 elements with style.cssText 2,722 406.4 148.3 18.35× 2.74×
Equal mix (sum of the five operation times) 8,800 1,698 830.8 10.59× 2.04×

Commit message

Integrate a shared CSS implementation into jsdom, based on https://github.com/cdoublev/css. Replace the separate selector, specificity, and color engines and the existing CSSOM implementation with parsing, matching, cascading, and serialization that share parsed values and participate directly in jsdom's DOM and stylesheet lifecycle.

Support custom-property substitution, typed attr() and conditional if() values, and property registration through @property and CSS.registerProperty(). Implement cascade layers, nesting, scopes, shadow-tree styling through :host, ::slotted(), and ::part(), and pseudo-element computed styles. Keep returned computed declarations live across DOM and CSSOM changes. Resolve declaration URLs against their original base URLs, apply display blockification, and handle descriptor-specific grammars and shorthand expansion. Update the HTML and SVG default stylesheets, including namespace restrictions that prevent HTML rules from hiding SVG titles.

Reuse candidate rules, selector matches, and individual computed properties within a document's style generation. Preserve layer order and property registrations across DOM mutations while their media conditions still match. Internal single-property reads use the same resolver without creating a public declaration. Match against implementation objects and native tree indexes, and defer CSSOM object creation and declaration parsing until needed. Bound shared syntax caches and keep their contents independent of DOM ownership, preventing unbounded selector-cache growth and retention of the first window used for computed styles.

Generate CSS grammars from patched CSSTree data and Webref, and language and directionality tables from maintained registry and Unicode packages. Keep grammar dependencies at build time; runtime parsing and validation use the integrated implementation. Compile initial values and the default stylesheet during preparation to avoid reparsing invariant data at startup.

Add regression coverage for cascade precedence, mutation invalidation, substitutions, serialization, and object lifetimes, together with selector and stylesheet scaling benchmarks. Update WPT expectations for the expanded support and link remaining specification and test disagreements to upstream discussions. This remains a non-layout implementation: animation playback, adopted stylesheets, container-query matching, Typed OM, and font loading remain outside its scope.

Four-round benchmarks against the parent revision on Node.js 26.8.2 show 13.8× faster querySelector() and 4.9× faster querySelectorAll() on the valid Sizzle corpus, and 2.7–2.8× faster complex-selector queries. The mutation-and-read benchmark is roughly 5–10× faster than the parent and stays nearly flat as the stylesheet grows from 16 to 1,024 rules; the parent’s cost nearly doubles. The equal mix of DOM creation and styling operations in #3985 is about 2× faster. These gains benefit from repeated syntax and deferred work; fresh declaration assignment and custom-property stylesheet parsing remain about 1.5× and 1.9× slower, respectively.

Fixes #1895. Fixes #1928. Fixes #2916. Fixes #2945. Fixes #2999. Fixes #3064. Fixes #3090. Fixes #3366. Fixes #3562. Fixes #3661. Fixes #3785. Fixes #3936. Fixes #3941. Fixes #3993. Fixes #4048. Fixes #4111. Fixes #4132. Fixes #4336. Fixes #4338. Fixes #4378. Fixes #4394. Fixes #4404. Fixes #4409.

Closes #4223. Closes #4379. Closes #4405. Closes #4410. Closes #4411. Closes #4412. Closes #4416.

domenic and others added 6 commits October 25, 2026 05:40
Integrate a shared CSS implementation into jsdom, based on https://github.com/cdoublev/css. Replace the separate selector, specificity, and color engines and the existing CSSOM implementation with parsing, matching, cascading, and serialization that share parsed values and participate directly in jsdom's DOM and stylesheet lifecycle.

Support custom-property substitution, typed `attr()` and conditional `if()` values, and property registration through `@property` and `CSS.registerProperty()`. Implement cascade layers, nesting, scopes, shadow-tree styling through `:host`, `::slotted()`, and `::part()`, and pseudo-element computed styles. Keep returned computed declarations live across DOM and CSSOM changes. Resolve declaration URLs against their original base URLs, apply display blockification, and handle descriptor-specific grammars and shorthand expansion. Update the HTML and SVG default stylesheets, including namespace restrictions that prevent HTML rules from hiding SVG titles.

Reuse candidate rules, selector matches, and individual computed properties within a document's style generation. Preserve layer order and property registrations across DOM mutations while their media conditions still match. Internal single-property reads use the same resolver without creating a public declaration. Match against implementation objects and native tree indexes, and defer CSSOM object creation and declaration parsing until needed. Bound shared syntax caches and keep their contents independent of DOM ownership, preventing unbounded selector-cache growth and retention of the first window used for computed styles.

Generate CSS grammars from patched CSSTree data and Webref, and language and directionality tables from maintained registry and Unicode packages. Keep grammar dependencies at build time; runtime parsing and validation use the integrated implementation. Compile initial values and the default stylesheet during preparation to avoid reparsing invariant data at startup.

Add regression coverage for cascade precedence, mutation invalidation, substitutions, serialization, and object lifetimes, together with selector and stylesheet scaling benchmarks. Update WPT expectations for the expanded support and link remaining specification and test disagreements to upstream discussions. This remains a non-layout implementation: animation playback, adopted stylesheets, container-query matching, Typed OM, and font loading remain outside its scope.

Four-round benchmarks against the parent revision on Node.js 26.8.2 show 13.8× faster `querySelector()` and 4.9× faster `querySelectorAll()` on the valid Sizzle corpus, and 2.7–2.8× faster complex-selector queries. The mutation-and-read benchmark is roughly 5–10× faster than the parent and stays nearly flat as the stylesheet grows from 16 to 1,024 rules; the parent’s cost nearly doubles. The equal mix of DOM creation and styling operations in #3985 is about 2× faster. These gains benefit from repeated syntax and deferred work; fresh declaration assignment and custom-property stylesheet parsing remain about 1.5× and 1.9× slower, respectively.

Fixes #1895. Fixes #1928. Fixes #2916. Fixes #2945. Fixes #2999. Fixes #3064. Fixes #3090. Fixes #3366. Fixes #3562. Fixes #3661. Fixes #3785. Fixes #3936. Fixes #3941. Fixes #3993. Fixes #4048. Fixes #4111. Fixes #4132. Fixes #4336. Fixes #4338. Fixes #4378. Fixes #4394. Fixes #4404. Fixes #4409.

Closes #4223. Closes #4379. Closes #4405. Closes #4410. Closes #4411. Closes #4412. Closes #4416.

Co-authored-by: codex <codex@openai.com>
Reuse immutable declaration parsing contexts for rules with the same grammar, namespace snapshot, and strictness. This avoids allocating a context per rule and repeatedly traversing those contexts when accounting for cached stylesheet syntax.

Three-round benchmarks reduce parsing time for 1,024-rule custom-property and ordinary stylesheets by about 6%, and custom-property parsing plus reads by about 8.5%.

Co-authored-by: codex <codex@openai.com>
Mark the two editing-host subtests in readwrite-readonly.html as expected failures because document.designMode is not implemented. The previous selector engine honored an expando property set by the test, allowing these cases to pass without implementing the editing state. Keep the remaining 23 subtests checked normally.

Co-authored-by: codex <codex@openai.com>
Narrow blanket exclusions to run 68 additional CSS and selector WPT files: 37 pass outright and 31 complete quickly with expected failures. This restores coverage for parsing, serialization, scripted focus, and selector invalidation.

Run the full upstream `:has()` complexity test and remove the reduced local duplicate. Replace several unknown timeout reasons with the missing APIs or setup behavior that prevents those tests from running.

Co-authored-by: codex <codex@openai.com>
Accept unnamed negated container queries while enforcing reserved container names in shorthands. Normalize recognized style-query declarations and preserve unknown container-query source to match browser serialization, with the unresolved specification requirements documented in the parser. Accept the specified `anchored` container type.

Allow `anchor()` and `anchor-size()` in calculations and nested fallbacks while enforcing their host-property restrictions. Keep anchor fallbacks outside the unordered name/side group, reject repeated subgrid auto-fill repetitions, sort and deduplicate computed font variation axes, and serialize string image-set sources as URLs.

Remove 14 passing WPT failure expectations and link the remaining test and specification disagreements to upstream PRs and issues.

Co-authored-by: codex <codex@openai.com>
Keep main's slow-test skips and retain the passing shadow-root stylesheet test enabled by the CSS implementation.

Co-authored-by: codex <codex@openai.com>
@asamuzaK

Copy link
Copy Markdown
Contributor

Awesome!
This is a major step forward for jsdom.
Let's keep on moving.

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