ReviewOS

stacks/very-happy-dom

getComputedStyle ignores stylesheets; only inline styles resolve

#1597
Closed glennmichael123 opened this 8 days ago ·
8 days ago

Summary

getComputedStyle() reads the element's inline style attribute and nothing else. Stylesheet rules are never consulted, so any style set through a class, id or type selector is invisible to it. document.styleSheets is a stub that returns [], so there is also no way for a caller to reach the rules and resolve them by hand.

The practical effect is that a class-based style or visibility assertion silently returns the wrong answer instead of failing. That makes it a poor failure mode: the test passes while checking nothing.

Reproduction

import { Window } from 'very-happy-dom'

const window = new Window()
const document = window.document

document.head.innerHTML = '<style>.hidden { display: none } .brand { color: rgb(255, 0, 0) }</style>'
document.body.innerHTML = '<div class="hidden">a</div><div class="brand">b</div>'

const hidden = document.querySelector('.hidden')
const brand = document.querySelector('.brand')

console.log(window.getComputedStyle(hidden).display) // "block"        — browsers: "none"
console.log(window.getComputedStyle(brand).color)    // "rgb(0, 0, 0)" — browsers: "rgb(255, 0, 0)"
console.log(document.styleSheets.length)             // 0             — browsers: 1

Confirmed against main at 99cf5b2 with Bun 1.4.3.

Inline styles do work, which is what masks this — <div style="display:none"> computes display: none correctly.

Why this matters more than it looks

Every project in this org styles with utility classes (crosswind), so essentially no real component has its styles inline. In that setting:

  • getComputedStyle(el).display cannot detect a hidden element.
  • Any helper built on it inherits the bug. isVisible()-style checks report visible for class="hidden".
  • Assertions of the form "this is hidden after the toggle" pass before the feature is written and keep passing after it breaks.

This is currently the blocker for documenting very-happy-dom as the component-testing path: the DOM layer is solid, but style and visibility assertions cannot be trusted.

Current implementation

VirtualDocument.getComputedStyle (src/nodes/VirtualDocument.ts:811) returns a Proxy whose get does:

const value = self.style.getPropertyValue(kebab)
if (value) return value
return computedDefaults(kebab, self.tagName)

self.style is the inline declaration. There is no rule matching anywhere in the path, and computedDefaults is a per-tag/per-property default table.

VirtualDocument.styleSheets (src/nodes/VirtualDocument.ts:216) is:

get styleSheets(): CSSStyleSheet[] {
  return []
}

Note that the pieces to fix this already exist and are unused by this path:

  • CSSStyleSheet.replaceSync() (src/css/CSSOM.ts:320) parses CSS text into CSSStyleRules with selectorText and a populated .style.
  • document.adoptedStyleSheets is implemented and holds real CSSStyleSheets.
  • The selector engine already matches arbitrary selectors against elements, via element.matches().

So this is wiring plus a cascade, not new parsing or matching.

Expected behavior

getComputedStyle should resolve a value in cascade order:

  1. !important declarations from stylesheets
  2. the inline style attribute
  3. normal stylesheet declarations, by descending specificity, then by document order for ties
  4. the existing per-tag defaults

and document.styleSheets should expose one CSSStyleSheet per <style> element in the document, in document order.

document.head.innerHTML = '<style>.hidden { display: none }</style>'
document.body.innerHTML = '<div class="hidden">a</div>'

getComputedStyle(document.querySelector('.hidden')).display // "none"

Specificity should follow the usual (id, class/attribute/pseudo-class, type) ordering, so #x { color: blue } beats .x { color: red } regardless of source order, and a later rule beats an earlier one of equal specificity.

Worth deciding explicitly

  • Inheritance. Browsers inherit color, font-*, visibility, line-height and others from the parent. This issue is about rules being applied at all; inherited properties could reasonably be a follow-up, but until then getComputedStyle(child).color will not see a colour set on the parent.
  • At-rules. replaceSync does not descend into @media/@supports blocks, so their inner rules are not matchable. They should be skipped rather than misapplied — a rule whose selector begins with @ must never match an element.
  • <link rel="stylesheet">. External stylesheets are not fetched, so they stay out of scope; only <style> elements and adoptedStyleSheets can contribute.
8 days ago

Fixed in 3af7403 (getComputedStyle consults the cascade: specificity, !important, and author-origin order). Released in v0.1.11, so this is now available to consumers rather than only on main. The scope notes I left earlier on this issue still stand — read those for what was deliberately left out.

Sign in to comment on this issue.