Merge pull request #2294 from navidemad/fix-a24-ua-stylesheet-display-none

css: apply UA stylesheet display:none defaults for unrendered elements
This commit is contained in:
Adrià Arrufat
2026-04-29 08:51:53 +02:00
committed by GitHub
4 changed files with 184 additions and 5 deletions

View File

@@ -45,6 +45,7 @@ pub const PointerEventsCache = std.AutoHashMapUnmanaged(*Element, bool);
const StyleManager = @This();
const Tag = Element.Tag;
const Input = Element.Html.Input;
const RuleList = std.MultiArrayList(VisibilityRule);
frame: *Frame,
@@ -234,14 +235,44 @@ pub fn isHidden(self: *StyleManager, el: *Element, cache: ?*VisibilityCache, opt
}
/// Computed display:none for a single element (own property, no ancestor walk).
/// Also honors the HTML `hidden` attribute, matching the UA stylesheet rule
/// `[hidden] { display: none }`.
/// Honors the UA stylesheet rules per HTML Rendering §15.3.1 "Hidden elements"
/// via `isElementHidden`.
pub fn hasDisplayNone(self: *StyleManager, el: *Element) bool {
self.rebuildIfDirty() catch return false;
if (el.hasAttributeSafe(comptime .wrap("hidden"))) return true;
return self.isElementHidden(el, .{});
}
/// Centralizes UA-stylesheet display:none truth so `getComputedStyle().display`
/// (via `hasDisplayNone`) and `el.checkVisibility()` (via `isHidden`) agree.
/// Spec: HTML Rendering §15.3.1 "Hidden elements".
fn matchesUaDisplayNoneRule(el: *Element) bool {
// Tag check first: O(1) switch, exits for the ~95% of elements with
// ordinary tags before we touch the attribute list.
const tag = el.getTag();
if (tag.isHiddenByUaStylesheet()) return true;
if (el.hasAttributeSafe(comptime .wrap("hidden"))) return true;
// input[type="hidden" i] { display: none !important }
// _input_type is parsed case-insensitively at attribute-set time.
if (tag == .input) {
if (el.is(Input)) |input| {
if (input._input_type == .hidden) return true;
}
}
// details:not([open]) > *:not(summary) { display: none }
if (tag != .summary) {
if (el.parentElement()) |parent| {
if (parent.getTag() == .details and !parent.hasAttributeSafe(comptime .wrap("open"))) {
return true;
}
}
}
return false;
}
/// Computed visibility:hidden for an element, considering only the `visibility`
/// chain (walks ancestors since `visibility` inherits by default). Ignores
/// display:none: an ancestor with display:none means the element isn't
@@ -313,6 +344,16 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
opacity_priority = INLINE_PRIORITY;
}
// UA stylesheet display:none rules (HTML Rendering §15.3.1 "Hidden elements").
// Skipped when an inline `display` is set so author overrides win per cascade.
// All UA rules are short-circuited here; the spec marks some as `!important`
// (`input[type=hidden]`, `noscript`) and others as normal-origin, but author
// CSS overriding `<script>`/`<head>`/closed-`<details>` children is rare
// enough that uniform treatment is acceptable.
if (options.check_display and display_priority != INLINE_PRIORITY) {
if (matchesUaDisplayNoneRule(el)) return true;
}
if (display_priority == INLINE_PRIORITY and visibility_priority == INLINE_PRIORITY and opacity_priority == INLINE_PRIORITY) {
return false;
}

View File

@@ -191,6 +191,115 @@
}
</script>
<script id="ua_unrendered_tags_are_display_none">
{
// Per HTML Rendering §15.3.1 "Hidden elements", these tags get
// display:none from the UA stylesheet:
// area, base, datalist, head, link, meta, noscript, param, script,
// source, style, template, title, track
// (basefont/noembed/noframes/rp are obsolete and not represented in
// Lightpanda's Element.Tag enum.)
const tags = [
'area', 'base', 'datalist', 'link', 'meta', 'noscript', 'param',
'script', 'source', 'style', 'template', 'title', 'track',
];
for (const t of tags) {
const el = document.createElement(t);
document.body.appendChild(el);
testing.expectEqual('none', window.getComputedStyle(el).display);
testing.expectEqual(false, el.checkVisibility());
el.remove();
}
// <head> itself
testing.expectEqual('none', window.getComputedStyle(document.head).display);
testing.expectEqual(false, document.head.checkVisibility());
}
</script>
<script id="ua_input_type_hidden_is_display_none">
{
// input[type="hidden" i] { display: none !important }
const inp = document.createElement('input');
inp.type = 'hidden';
document.body.appendChild(inp);
testing.expectEqual('none', window.getComputedStyle(inp).display);
testing.expectEqual(false, inp.checkVisibility());
// Other input types are visible
inp.type = 'text';
testing.expectEqual(true, inp.checkVisibility());
// Spec uses [type=hidden i] (case-insensitive)
inp.type = 'HIDDEN';
testing.expectEqual(false, inp.checkVisibility());
inp.remove();
}
</script>
<script id="ua_closed_details_hides_non_summary_children">
{
// details:not([open]) > *:not(summary) { display: none }
const details = document.createElement('details');
const summary = document.createElement('summary');
summary.textContent = 'header';
const child = document.createElement('p');
child.textContent = 'body';
details.appendChild(summary);
details.appendChild(child);
document.body.appendChild(details);
// Closed: <summary> visible, body child hidden
testing.expectEqual(true, summary.checkVisibility());
testing.expectEqual(false, child.checkVisibility());
testing.expectEqual('none', window.getComputedStyle(child).display);
// Open: child visible
details.setAttribute('open', '');
testing.expectEqual(true, child.checkVisibility());
// Closed again
details.removeAttribute('open');
testing.expectEqual(false, child.checkVisibility());
// Descendants of a closed-details child are hidden via the ancestor walk.
const grandchild = document.createElement('span');
child.appendChild(grandchild);
testing.expectEqual(false, grandchild.checkVisibility());
details.remove();
}
</script>
<script id="hidden_attribute_propagates_through_check_visibility">
{
// [hidden] { display: none } applies to the element itself...
const parent = document.createElement('div');
parent.setAttribute('hidden', '');
const child = document.createElement('span');
parent.appendChild(child);
document.body.appendChild(parent);
testing.expectEqual(false, parent.checkVisibility());
// ...and through ancestor walking, hides descendants too.
testing.expectEqual(false, child.checkVisibility());
parent.remove();
}
</script>
<script id="inline_display_overrides_ua_default">
{
// Author inline `display: block` on a UA-hidden tag wins per CSS cascade
// (inline > UA stylesheet).
const s = document.createElement('script');
s.style.display = 'block';
document.body.appendChild(s);
testing.expectEqual(true, s.checkVisibility());
s.remove();
}
</script>
<script id="deep_nesting">
{
const levels = 5;

View File

@@ -1694,6 +1694,32 @@ pub const Tag = enum {
else => false,
};
}
// UA stylesheet display:none defaults per HTML Rendering §15.3.1
// "Hidden elements" (https://html.spec.whatwg.org/multipage/rendering.html#hidden-elements).
// The spec also lists basefont, noembed, noframes, rp; those tags are
// obsolete and not represented in this enum, so they fall through to
// `.unknown`/`.custom` and aren't matched here.
pub fn isHiddenByUaStylesheet(self: Tag) bool {
return switch (self) {
.area,
.base,
.datalist,
.head,
.link,
.meta,
.noscript,
.param,
.script,
.source,
.style,
.template,
.title,
.track,
=> true,
else => false,
};
}
};
pub const JsApi = struct {

View File

@@ -713,13 +713,16 @@ test "cdp.dom: getBoxModel" {
});
try ctx.expectSentResult(.{ .nodeId = 3 }, .{ .id = 4 });
// Box model on the <p> nodeId returned above.
// Note: nodeId 6 is <head>, which is `display: none` per HTML Rendering
// §15.3.1, so its box model is all-zeros — exercise a visible element.
try ctx.processMessage(.{
.id = 5,
.method = "DOM.getBoxModel",
.params = .{ .nodeId = 6 },
.params = .{ .nodeId = 3 },
});
try ctx.expectSentResult(.{ .model = BoxModel{
.content = Quad{ 10.0, 10.0, 15.0, 10.0, 15.0, 15.0, 10.0, 15.0 },
.content = Quad{ 25.0, 25.0, 30.0, 25.0, 30.0, 30.0, 25.0, 30.0 },
.padding = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
.border = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
.margin = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },