Skip to content

css: getComputedStyle returns "" for all properties except display/visibility (stylesheet and inline declarations both dropped) #2733

@navidemad

Description

@navidemad

Summary

getComputedStyle() returns the empty string for every CSS property except display and visibility. This affects declarations from both sources: stylesheet rules never reach the computed style, and even inline style= declarations — which are correctly readable through el.style — come back as "" through the computed-style object. Per CSSOM §resolved values, getComputedStyle() must return the resolved value of every supported property; Chrome returns "uppercase" / "700" for the fixture below, and returns the property's initial value ("none", "400") when nothing matches.

Downstream CDP clients use getComputedStyle for far more than visibility: text-transform-aware text matching, font-weight assertions, color extraction, accessibility tree heuristics. With every property except display/visibility resolving to "", all of those silently degrade.

Today's behavior

A CDP client evaluating getComputedStyle(el).textTransform via Runtime.evaluate gets "" — whether the declaration comes from a stylesheet rule (#styled { text-transform: uppercase }) or from the element's own style attribute. The same inline declaration reads fine via el.style.textTransform. Only display and visibility are special-cased to consult the stylesheet cascade.

sequenceDiagram
    participant Client as CDP Client
    participant LP as Lightpanda
    participant SM as StyleManager
    Client->>LP: Runtime.evaluate getComputedStyle textTransform
    LP->>SM: computed branch consults cascade
    SM-->>LP: only display and visibility are resolved
    LP-->>Client: empty string for every other property
    Note over LP,SM: src/browser/webapi/css/CSSStyleDeclaration.zig getPropertyValue
Loading

Expected behavior

Per CSSOM §dom-window-getcomputedstyle, the returned declaration block exposes the resolved value of supported properties. Chrome resolves the full cascade: author stylesheet rules by specificity, then inline declarations, then inheritance/initial values.

sequenceDiagram
    participant Client as CDP Client
    participant LP as Lightpanda
    participant SM as StyleManager
    Client->>LP: Runtime.evaluate getComputedStyle textTransform
    LP->>SM: computed branch consults cascade
    SM-->>LP: matched declarations resolved by specificity
    LP-->>Client: uppercase as in Chrome
Loading

Scope question (before any PR)

The current architecture explains the gap: StyleManager tracks matched rules as three booleans (display_none / visibility_hidden / opacity_zero) rather than retaining the matched declarations, and CSSStyleDeclaration.getPropertyValue's _is_computed branch special-cases exactly those two properties. Generalizing means deciding how much cascade to keep:

  1. Per-property extension — keep the boolean-style fast path, add the handful of properties downstream clients consult most (text-transform, font-weight, color, background-color, opacity, ...), resolved from matched rules by specificity. Cheap, but each property is a new special case.
  2. General declaration retentionStyleManager stores the full declaration list per matched rule; getPropertyValue resolves any property by walking matches in specificity/source order, falling back to inline, then to a small initial-value table. Bigger change, no per-property whack-a-mole.

Also worth deciding: whether unmatched properties should keep returning "" or synthesize initial values ("none", "400") as Chrome does — the latter is what the spec's resolved-value text implies.

Happy to implement either direction — filing this first to agree on the approach before writing code.

Reproducer

Self-contained: one HTML fixture + one shell script + a small Node CDP driver (only dependency: npm install --no-save ws, installed automatically).

repro.html:

<!DOCTYPE html>
<html>
<head>
  <title>computed-style cascade repro</title>
  <style>
    #styled { text-transform: uppercase; font-weight: 700; }
  </style>
</head>
<body>
  <p id="styled">hello</p>
  <p id="inline" style="text-transform: uppercase">hello (inline control)</p>
</body>
</html>

repro.sh:

#!/usr/bin/env bash
# Asserts that getComputedStyle resolves properties from stylesheet rules
# (cascade) and inline declarations, not only display/visibility.
#
# Prerequisites: node >= 18, python3, a lightpanda binary on PATH or LIGHTPANDA_BIN=...
# Run:           ./repro.sh
# Expected today (bug): computed textTransform: ""            exit 1
# Expected after fix:   computed textTransform: "uppercase"   exit 0

set -euo pipefail

REPRO_DIR="$(cd "$(dirname "$0")" && pwd)"
LIGHTPANDA_BIN="${LIGHTPANDA_BIN:-lightpanda}"
HTTP_PORT="${HTTP_PORT:-9580}"
CDP_PORT="${CDP_PORT:-9222}"

# Kill any prior listeners on our ports — a stale lightpanda will silently
# keep answering CDP and break the harness.
lsof -ti:"$CDP_PORT"  2>/dev/null | xargs -r kill 2>/dev/null || true
lsof -ti:"$HTTP_PORT" 2>/dev/null | xargs -r kill 2>/dev/null || true

PIDS=()
cleanup() {
  for pid in "${PIDS[@]:-}"; do kill "$pid" 2>/dev/null || true; done
}
trap cleanup EXIT

cd "$REPRO_DIR"
python3 -m http.server "$HTTP_PORT" >http.log 2>&1 &
PIDS+=("$!")

LIGHTPANDA_DISABLE_TELEMETRY=true \
  "$LIGHTPANDA_BIN" serve --host 127.0.0.1 --port "$CDP_PORT" >lightpanda.log 2>&1 &
PIDS+=("$!")

# Readiness probe: assert the listener is *Lightpanda*, not a stale Chrome or
# a port collision answering 200s.
for _ in $(seq 1 50); do
  body="$(curl -fsS "http://127.0.0.1:${CDP_PORT}/json/version" 2>/dev/null || true)"
  if [[ "$body" == *'"Browser": "Lightpanda'* ]]; then break; fi
  sleep 0.1
done

if [ ! -d node_modules/ws ]; then
  npm install --no-save --silent ws
fi

HTTP_PORT="$HTTP_PORT" exec node probe.js

probe.js:

// Asserts getComputedStyle resolves cascade properties (from stylesheet
// rules and inline declarations). Today it returns "" for everything except
// display/visibility. Control: the inline declaration is readable via
// el.style, so the value exists — only the computed-style path drops it.
const { connect } = require('./cdp.js');

const HTTP_PORT = process.env.HTTP_PORT || 9580;

(async () => {
  const c = await connect();
  await c.navigate(`http://127.0.0.1:${HTTP_PORT}/repro.html`);

  const control = await c.eval(`document.getElementById('inline').style.textTransform`);
  const fromSheet = await c.eval(`getComputedStyle(document.getElementById('styled')).textTransform`);
  const fromInline = await c.eval(`getComputedStyle(document.getElementById('inline')).textTransform`);
  const display = await c.eval(`getComputedStyle(document.getElementById('styled')).display`);

  console.log(`el.style.textTransform (control):   ${JSON.stringify(control)} (expected "uppercase")`);
  console.log(`computed textTransform, stylesheet: ${JSON.stringify(fromSheet)} (expected "uppercase")`);
  console.log(`computed textTransform, inline:     ${JSON.stringify(fromInline)} (expected "uppercase")`);
  console.log(`computed display, stylesheet:       ${JSON.stringify(display)} (expected "block" — already works)`);

  c.close();
  if (control !== 'uppercase') {
    console.log('BROKEN HARNESS: the inline declaration is not readable via el.style.');
    process.exit(2);
  }
  if (fromSheet !== 'uppercase' || fromInline !== 'uppercase') {
    console.log('FAIL: getComputedStyle returns "" for cascade properties.');
    process.exit(1);
  }
  console.log('PASS: getComputedStyle resolves cascade properties.');
  process.exit(0);
})().catch(e => { console.error(e); process.exit(2); });
cdp.js (raw-ws CDP helper: connect + createTarget + attach + eval)
// Reusable CDP scaffolding (raw `ws`; Lightpanda's /json/list is empty and
// /json/protocol is absent, so chrome-remote-interface needs more patching
// than this is long).
const WebSocket = require('ws');

function makeClient(url = 'ws://127.0.0.1:9222/') {
  const ws = new WebSocket(url);
  let nextId = 1;
  const pending = new Map();
  const handlers = new Map();
  const drainPending = (reason) => {
    for (const { reject } of pending.values()) reject(new Error(reason));
    pending.clear();
  };
  ws.on('message', (raw) => {
    const m = JSON.parse(raw.toString());
    if (m.id != null && pending.has(m.id)) {
      const { resolve, reject } = pending.get(m.id);
      pending.delete(m.id);
      if (m.error) reject(new Error(`${m.error.code}: ${m.error.message}`));
      else resolve(m.result);
    } else if (m.method) {
      const fn = handlers.get(m.method);
      if (fn) fn(m.params, m.sessionId);
    }
  });
  ws.on('close', () => drainPending('WebSocket closed before response'));
  ws.on('error', (e) => drainPending(`WebSocket error: ${e.message}`));
  return {
    send(method, params = {}, sessionId) {
      const id = nextId++;
      return new Promise((resolve, reject) => {
        pending.set(id, { resolve, reject });
        ws.send(JSON.stringify({ id, method, params, sessionId }));
      });
    },
    on(method, fn) { handlers.set(method, fn); },
    ready() {
      return new Promise((resolve, reject) => {
        ws.on('open', resolve);
        ws.on('error', (e) => reject(new Error(`WebSocket connect failed: ${e.message}`)));
      });
    },
    close() { ws.close(); },
  };
}

async function connect(opts = {}) {
  const url = opts.url || 'ws://127.0.0.1:9222/';
  const client = makeClient(url);
  await client.ready();
  const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' });
  const { sessionId } = await client.send('Target.attachToTarget', { targetId, flatten: true });
  await client.send('Page.enable', {}, sessionId);
  await client.send('Runtime.enable', {}, sessionId);

  return {
    sessionId,
    close: () => client.close(),
    async eval(expression) {
      const r = await client.send('Runtime.evaluate', {
        expression, returnByValue: true,
      }, sessionId);
      if (r.exceptionDetails) throw new Error(`JS exception: ${r.exceptionDetails.text}`);
      return r.result && r.result.value;
    },
    async navigate(url2) {
      let loaded = false;
      client.on('Page.loadEventFired', () => { loaded = true; });
      await client.send('Page.navigate', { url: url2 }, sessionId);
      for (let i = 0; i < 30 && !loaded; i++) {
        await new Promise(r => setTimeout(r, 100));
        try {
          const r = await client.send('Runtime.evaluate', {
            expression: 'document.readyState', returnByValue: true,
          }, sessionId);
          if (r.result && r.result.value === 'complete') break;
        } catch (e) {
          if (!/Cannot find default execution context/.test(e.message)) throw e;
        }
      }
    },
  };
}

module.exports = { connect };

Run: bash repro.sh

  • Today (nightly 1.0.0-nightly.6736+63665f81):
    el.style.textTransform (control):   "uppercase" (expected "uppercase")
    computed textTransform, stylesheet: "" (expected "uppercase")
    computed textTransform, inline:     "" (expected "uppercase")
    computed display, stylesheet:       "block" (expected "block" — already works)
    FAIL: getComputedStyle returns "" for cascade properties.
    
    exits 1.
  • Expected: all four lines resolve, prints PASS: getComputedStyle resolves cascade properties. and exits 0.

Chrome control (same fixture, chrome-headless-shell): textTransform: "uppercase", fontWeight: "700" for the stylesheet element.

Likely fix location

  • src/browser/webapi/css/CSSStyleDeclaration.ziggetPropertyValue: the _is_computed branch special-cases only display/visibility.
  • src/browser/StyleManager.zig — the cascade walk reduces matched rules to three booleans (display_none/visibility_hidden/opacity_zero); resolving other properties needs the matched declarations retained in some form (see scope question above).

Environment

  • Lightpanda: nightly 1.0.0-nightly.6736+63665f81 (macOS aarch64); special-casing confirmed in source on main
  • OS: darwin (macOS 15, aarch64)
  • CDP client: raw ws (Node 26)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions