From b684e8b62bf5d4c5eea5fd5e587166dc61f0cba1 Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:00:42 +0100 Subject: [PATCH] Implement effective CSS property cascade semantics --- ...css-effective-property-cascade-20260917.md | 58 ++++ ...s-effective-property-cascade-20260917.json | 38 +++ .../native/webscene_css_application.h | 8 +- .../native/webscene_css_cascade_application.h | 30 +- .../webscene_css_cascade_finalization.h | 9 +- .../native/webscene_css_cascade_reset.h | 5 +- .../native/webscene_css_native_cascade.h | 1 + .../native/webscene_css_property_mask.h | 2 + .../native/webscene_css_reset.h | 50 ++- .../native/webscene_css_rule_operations.h | 285 ++++++++++++++++-- .../native/webscene_css_variables.h | 7 +- .../native/webscene_native_dom.h | 45 +++ .../native/webscene_native_dom_metrics.inc | 6 +- .../webscene_v8_runtime_css_parsing.inc | 1 + .../native/webscene_v8_runtime_style.inc | 197 +++++++----- .../tests/native_css_invalidation_tests.inc | 22 +- .../css-effective-property-cascade.html | 233 ++++++++++++++ .../webscene-component-profile.json | 7 + 18 files changed, 872 insertions(+), 132 deletions(-) create mode 100644 docs/validation/css-effective-property-cascade-20260917.md create mode 100644 docs/validation/evidence/css-effective-property-cascade-20260917.json create mode 100644 tests/WebPlatformSubset/contracts/css-effective-property-cascade.html diff --git a/docs/validation/css-effective-property-cascade-20260917.md b/docs/validation/css-effective-property-cascade-20260917.md new file mode 100644 index 000000000..3d45dc849 --- /dev/null +++ b/docs/validation/css-effective-property-cascade-20260917.md @@ -0,0 +1,58 @@ +# Effective-property cascade validation (2026-09-17) + +This stage of issue #235 moves rollback and overlapping inline declarations from +authored property names to the effective longhands they control. It covers the +high-frequency box shorthands, `all`, and CSSOM declaration order without adding +expansion work to the ordinary no-rollback stylesheet cascade. + +## Browser contract + +`tests/WebPlatformSubset/contracts/css-effective-property-cascade.html` is a +product-neutral 12-subtest contract. Chrome 153.0.8010.48 passes 12/12. Before +the implementation, the native engine passed 1/8 of the original contract; +margin, padding, inset, `all`, and inline ordering checks failed. + +The final native engine passes 12/12 with both parser configurations: + +- current CSSParser build: 12/12 +- legacy parser build: 12/12 +- unchanged cascade winner matrix: 17/17 + +The contract checks effective-longhand rollback for margin, padding, inset, +border width/color, gap, and overflow; `all: revert-layer`; important +`all: unset`; font/line-height winner identity; and inline CSSOM ordering, +removal, serialization, and recascade behavior. + +## Scaling and compatibility checks + +The native cascade-layer scaling regression retains exact work invariance as +unrelated DOM size grows: + +| affected | unrelated | selector candidates | fallback visits | rule checks | applications | cascade candidates | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 8 | 32 | 16 | 0 | 32 | 18 | 32 | +| 8 | 1024 | 16 | 0 | 32 | 18 | 32 | +| 128 | 32 | 256 | 0 | 512 | 258 | 512 | +| 128 | 1024 | 256 | 0 | 512 | 258 | 512 | + +Adjacent native contracts also pass: `css-all-unset-primitives` (2/2), +`cssom-inline-declaration-validity` (4/4), `cssom-padding-assignment` (5/5), +and `cssom-border-assignment` (5/5). The shared NativeWeb CSS service, shared +styles, and compiler CTests pass 3/3. + +## Performance boundary + +The common stylesheet cascade path remains a direct declaration replay. Only a +sheet containing `revert` or `revert-layer` constructs effective-longhand +winners and tokenizes recognized shorthands. Inline declarations use authored +order only when CSSOM mutation, `all`, or overlapping shorthand masks require a +full replay. + +This is a bounded stage, not a claim of complete CSS Cascade support. Complex +shorthands such as `background`, `flex`, `transition`, and grid shorthands still +need a general expansion model. `all: initial` and `all: inherit`, UA/user +origins, broader inherited-property coverage, and complete CSSOM priority +projection remain follow-up work in issue #235. + +Machine-readable results are summarized in +`docs/validation/evidence/css-effective-property-cascade-20260917.json`. diff --git a/docs/validation/evidence/css-effective-property-cascade-20260917.json b/docs/validation/evidence/css-effective-property-cascade-20260917.json new file mode 100644 index 000000000..6aa803662 --- /dev/null +++ b/docs/validation/evidence/css-effective-property-cascade-20260917.json @@ -0,0 +1,38 @@ +{ + "date": "2026-09-17", + "issue": 235, + "contract": "tests/WebPlatformSubset/contracts/css-effective-property-cascade.html", + "chrome": { + "version": "153.0.8010.48", + "documentsPassed": 1, + "documentsTotal": 1, + "subtestsPassed": 12, + "subtestsTotal": 12 + }, + "nativeBaseline": { + "subtestsPassed": 1, + "subtestsTotal": 8 + }, + "nativeCurrentParser": { + "subtestsPassed": 12, + "subtestsTotal": 12 + }, + "nativeLegacyParser": { + "subtestsPassed": 12, + "subtestsTotal": 12 + }, + "unchangedCascadeWinnerMatrix": { + "subtestsPassed": 17, + "subtestsTotal": 17 + }, + "nativeWebCtest": { + "testsPassed": 3, + "testsTotal": 3 + }, + "scaling": [ + {"affected": 8, "unrelated": 32, "selectorCandidates": 16, "fallbackVisits": 0, "ruleChecks": 32, "cascadeApplications": 18, "cascadeCandidates": 32}, + {"affected": 8, "unrelated": 1024, "selectorCandidates": 16, "fallbackVisits": 0, "ruleChecks": 32, "cascadeApplications": 18, "cascadeCandidates": 32}, + {"affected": 128, "unrelated": 32, "selectorCandidates": 256, "fallbackVisits": 0, "ruleChecks": 512, "cascadeApplications": 258, "cascadeCandidates": 512}, + {"affected": 128, "unrelated": 1024, "selectorCandidates": 256, "fallbackVisits": 0, "ruleChecks": 512, "cascadeApplications": 258, "cascadeCandidates": 512} + ] +} diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h index ae8a1b7f4..169d00735 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h @@ -43,17 +43,17 @@ void apply_resolved_declaration(native_document& document,dom_node& node, & property) != 0U; }; if (name == "all") { - if (value != "unset" || declaration.important) { + if (!cascade_keyword_is(value, "unset")) { decision.classification = "unsupported"; decision.semantic_slice = - "non-important unset across modeled properties, excluding custom properties"; + "unset across modeled properties, excluding custom properties"; return; } - css::apply_all_unset(node); + css::apply_all_unset(node, declaration.important, inline_origin); decision.classification = "partially-supported"; decision.semantic_slice = - "non-important unset across modeled properties, excluding custom properties"; + "unset across modeled properties, excluding custom properties"; return; } if (name == "color" && !is_inline(inline_color) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_application.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_application.h index 9693ad8eb..6b885a825 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_application.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_application.h @@ -32,19 +32,36 @@ void apply_matched_declarations(dom_node& node,const cascaded_rule_order& order, // Temporarily removing the inline guard lets a declaration update its own // computed value while the important-origin mask still prevents a // normal inline declaration from overriding an author !important rule. - for (const auto& [name, value] : node.authored_style().declarations) { + uint64_t inline_groups = 0U; + bool ordered_inline_replay = + node.authored_style().requires_full_replay; + node.authored_style().for_each_declaration( + [&](const std::string& name, const std::string&) { + if (name.starts_with("--") + || node.authored_style().important_declarations.contains(name)) { + return; + } + const auto group = css::property_mask(name); + ordered_inline_replay = ordered_inline_replay || name == "all" + || (group != 0U && (inline_groups & group) != 0U); + inline_groups |= group; + }); + node.authored_style().for_each_declaration( + [&](const std::string& name, const std::string& value) { const auto inline_important = node.authored_style().important_declarations.contains(name); const auto inherited_dimension = (name == "width" || name == "height") && trim_css_view(value) == "inherit"; if (name.starts_with("--") || inline_important - || (value.find("var(") == std::string::npos && !inherited_dimension)) continue; + || (!ordered_inline_replay + && value.find("var(") == std::string::npos + && !inherited_dimension)) return; const auto property_mask = css::property_mask(name); const auto retained_inline_mask = node.style.inline_property_mask; node.style.inline_property_mask &= ~property_mask; apply({name, value, false}, true); node.style.inline_property_mask = retained_inline_mask; - } + }); // Inline transition declarations are stored separately from the hot // style object. Recascade clears cold animation state before applying // stylesheet rules, so restore the inline origin afterward while @@ -72,17 +89,18 @@ void apply_matched_declarations(dom_node& node,const cascaded_rule_order& order, // rule list is applied. Replaying only the authored important tier // restores the CSS cascade order without reapplying every ordinary // inline declaration or adding a mutation-lived winner cache. - for (const auto& [name, value] : node.authored_style().declarations) { + node.authored_style().for_each_declaration( + [&](const std::string& name, const std::string& value) { if (name.starts_with("--") || !node.authored_style().important_declarations.contains(name)) { - continue; + return; } const auto property_mask = css::property_mask(name); const auto retained_inline_mask = node.style.inline_property_mask; node.style.inline_property_mask &= ~property_mask; apply({name, value, true}, true); node.style.inline_property_mask = retained_inline_mask; - } + }); } template diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_finalization.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_finalization.h index f7e240002..f0b185e8d 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_finalization.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_finalization.h @@ -225,13 +225,14 @@ inline void recompute_cascaded_line_height(dom_node& node, const cascaded_rule_o if (declaration.name == "font" || declaration.name == "line-height") return "line-height"; return declaration.name; - }); + }); for (const auto important : {false, true}) { - for (const auto& [name, value] : node.authored_style().declarations) { + node.authored_style().for_each_declaration( + [&](const std::string& name, const std::string& value) { if (node.authored_style().important_declarations.contains(name) != important) - continue; + return; consider({name, value, important}); - } + }); } if (winning_value.has_value()) { const auto font_size = node.style.font_size >= 0 diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h index 4ef9b8be4..6bdfd4655 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h @@ -68,12 +68,13 @@ inline void reset_cascaded_style(dom_node& node, // Rebuild from authored inline values, not a previous computed // value that may have been replaced by an important stylesheet. for (const auto important : {false, true}) { - for (const auto& [name, value] : node.authored_style().declarations) { + node.authored_style().for_each_declaration( + [&](const std::string& name, const std::string& value) { if (margin_sides(name) != 0U && node.authored_style().important_declarations.contains(name) == important) { apply_margin_declaration(node.style, name, resolve_value(node,value,variables)); } - } + }); } } if ((node.style.inline_property_mask & inline_border) == 0U) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_native_cascade.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_native_cascade.h index 56f6564df..8ec90dece 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_native_cascade.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_native_cascade.h @@ -35,6 +35,7 @@ bool apply_native_cascade(native_document& document,dom_node& node, auto& authored=node.mutable_authored_style(); if(!declaration.important && authored.important_declarations.contains(declaration.name)) continue; authored.declarations[declaration.name]=declaration.value; + authored.record_declaration_order(declaration.name); if(declaration.important) authored.important_declarations.insert(declaration.name); } } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_property_mask.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_property_mask.h index 48efaf028..117d4864b 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_property_mask.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_property_mask.h @@ -1,5 +1,6 @@ #pragma once #include "webscene_css_box_values.h" +#include namespace webscene_native::css { // Existing native style storage groups used for inline/important precedence. @@ -74,6 +75,7 @@ enum inline_style_property : uint64_t { }; inline uint64_t property_mask(std::string_view name) { + if (name == "all") return std::numeric_limits::max(); if (name == "stroke-width") return inline_svg_stroke_width; if (name == "scrollbar-width") return inline_scrollbar_width; if (name == "scrollbar-color") return inline_scrollbar_color; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h index 083b9a273..bd99250a4 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h @@ -6,10 +6,29 @@ namespace webscene_native::css { // Existing modeled behavior for a non-important author all:unset declaration. // Callers handle unsupported reset keywords and cascade ordering before entry. -inline void apply_all_unset(dom_node& node) +inline void apply_all_unset( + dom_node& node, + bool important = false, + bool inline_origin = false) { const auto is_inline=[&](uint64_t property) { - return ((node.style.inline_property_mask|node.style.important_property_mask)&property)!=0; + if (inline_origin) { + // An inline `all` replaces earlier declarations in its own block. + // Only a higher author-important rule survives inline-normal all. + return !important + && (node.style.important_property_mask & property) != 0U; + } + if (!important) { + return ((node.style.inline_property_mask + | node.style.important_property_mask) & property) != 0U; + } + // Author-important all still yields to inline-important declarations. + return std::any_of( + node.authored_style().important_declarations.begin(), + node.authored_style().important_declarations.end(), + [&](const std::string& name) { + return (property_mask(name) & property) != 0U; + }); }; // `all: unset` is a common component-control reset. Start from the // modeled initial/inherited sentinels, then restore declarations @@ -20,17 +39,32 @@ inline void apply_all_unset(dom_node& node) // element's declarations. auto previous = node.style; auto reset = node_style{}; - reset.display = native_default_display_for_node(node); - reset.inline_property_mask = previous.inline_property_mask; - reset.important_property_mask = previous.important_property_mask; - reset.important_margin_sides = previous.important_margin_sides; + // `unset` selects the CSS initial value for non-inherited properties. The + // element-specific block/table defaults belong to the lower UA origin and + // must not replace author `all: unset` (whose initial display is inline). + reset.display = display_mode::inline_flow; + reset.inline_property_mask = inline_origin + ? std::numeric_limits::max() + : previous.inline_property_mask; + reset.important_property_mask = important + ? std::numeric_limits::max() + : previous.important_property_mask; + reset.important_margin_sides = important + ? 15U + : previous.important_margin_sides; reset.move_custom_properties_from(previous); reset.move_pseudo_elements_from(previous); const auto has_inline = [&](std::initializer_list names) { return std::any_of(names.begin(), names.end(), [&](std::string_view candidate) { - return node.authored_style().declarations.contains( - std::string(candidate)); + if (!node.authored_style().declarations.contains( + std::string(candidate))) { + return false; + } + if (inline_origin) return false; + return !important + || node.authored_style().important_declarations.contains( + std::string(candidate)); }); }; if (is_inline(inline_width)) reset.width = previous.width; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_rule_operations.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_rule_operations.h index 3e1f86719..3049f20f5 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_rule_operations.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_rule_operations.h @@ -103,6 +103,186 @@ struct cascade_layer_property_hash final { } }; +// Rollback is defined over effective longhands, not the authored spelling. +// Keep expansion on the rare rollback path: the ordinary cascade remains a +// zero-expansion declaration replay, while a sheet containing revert keywords +// pays the small amount of parsing needed to distinguish shorthand components. +template +void for_each_effective_box_declaration( + const css_declaration& declaration, + Apply&& apply) +{ + const auto emit = [&](std::string_view name, std::string_view value) { + css_declaration effective{ + std::string(name), std::string(value), declaration.important}; + apply(effective, name); + }; + const auto& name = declaration.name; + if (name == "margin-inline-start") { + emit("margin-left", declaration.value); + return; + } + if (name == "margin-inline-end") { + emit("margin-right", declaration.value); + return; + } + if (name == "margin-block-start") { + emit("margin-top", declaration.value); + return; + } + if (name == "margin-block-end") { + emit("margin-bottom", declaration.value); + return; + } + if (name == "padding-inline-start") { + emit("padding-left", declaration.value); + return; + } + if (name == "padding-inline-end") { + emit("padding-right", declaration.value); + return; + } + if (name == "padding-block-start") { + emit("padding-top", declaration.value); + return; + } + if (name == "padding-block-end") { + emit("padding-bottom", declaration.value); + return; + } + if (name == "inset-inline-start") { + emit("left", declaration.value); + return; + } + if (name == "inset-inline-end") { + emit("right", declaration.value); + return; + } + if (name == "inset-block-start") { + emit("top", declaration.value); + return; + } + if (name == "inset-block-end") { + emit("bottom", declaration.value); + return; + } + if (name == "border-inline-start-width") { + emit("border-left-width", declaration.value); + return; + } + if (name == "border-inline-end-width") { + emit("border-right-width", declaration.value); + return; + } + if (name == "border-block-start-width") { + emit("border-top-width", declaration.value); + return; + } + if (name == "border-block-end-width") { + emit("border-bottom-width", declaration.value); + return; + } + if (name == "border-inline-start-color") { + emit("border-left-color", declaration.value); + return; + } + if (name == "border-inline-end-color") { + emit("border-right-color", declaration.value); + return; + } + if (name == "border-block-start-color") { + emit("border-top-color", declaration.value); + return; + } + if (name == "border-block-end-color") { + emit("border-bottom-color", declaration.value); + return; + } + const auto expands_four = name == "margin" || name == "padding" + || name == "inset" || name == "border-width" + || name == "border-color"; + const auto expands_two = name == "margin-block" || name == "margin-inline" + || name == "padding-block" || name == "padding-inline" + || name == "gap" || name == "overflow"; + if (!expands_four && !expands_two) { + apply(declaration, std::string_view(declaration.name)); + return; + } + const auto tokens = [&]() { + std::array result{}; + size_t count = 0; + size_t start = std::string_view::npos; + int depth = 0; + const auto value = std::string_view(declaration.value); + for (size_t index = 0; index <= value.size(); ++index) { + const auto character = index < value.size() ? value[index] : ' '; + if (character == '(') ++depth; + else if (character == ')' && depth > 0) --depth; + if (std::isspace(static_cast(character)) && depth == 0) { + if (start == std::string_view::npos) continue; + if (count == result.size()) return std::pair{result, size_t{0}}; + result[count++] = value.substr(start, index - start); + start = std::string_view::npos; + } else if (start == std::string_view::npos) { + start = index; + } + } + return std::pair{result, count}; + }(); + const auto& values = tokens.first; + const auto count = tokens.second; + const auto four_sides = [&](std::string_view top, std::string_view right, + std::string_view bottom, std::string_view left) { + if (count == 0U || count > 4U) { + apply(declaration, std::string_view(declaration.name)); + return; + } + emit(top, values[0]); + emit(right, count > 1U ? values[1] : values[0]); + emit(bottom, count > 2U ? values[2] : values[0]); + emit(left, count > 3U ? values[3] : count > 1U ? values[1] : values[0]); + }; + const auto two_sides = [&](std::string_view start, std::string_view end) { + if (count == 0U || count > 2U) { + apply(declaration, std::string_view(declaration.name)); + return; + } + emit(start, values[0]); + emit(end, count > 1U ? values[1] : values[0]); + }; + if (name == "margin") { + four_sides("margin-top", "margin-right", "margin-bottom", "margin-left"); + } else if (name == "margin-block") { + two_sides("margin-top", "margin-bottom"); + } else if (name == "margin-inline") { + two_sides("margin-left", "margin-right"); + } else if (name == "padding") { + four_sides("padding-top", "padding-right", "padding-bottom", "padding-left"); + } else if (name == "padding-block") { + two_sides("padding-top", "padding-bottom"); + } else if (name == "padding-inline") { + two_sides("padding-left", "padding-right"); + } else if (name == "inset") { + four_sides("top", "right", "bottom", "left"); + } else if (name == "border-width") { + four_sides("border-top-width", "border-right-width", + "border-bottom-width", "border-left-width"); + } else if (name == "border-color") { + four_sides("border-top-color", "border-right-color", + "border-bottom-color", "border-left-color"); + } else if (name == "gap") { + two_sides("row-gap", "column-gap"); + } else if (name == "overflow") { + two_sides("overflow-x", "overflow-y"); + } +} + +struct cascade_rollback_winner final { + size_t sequence{}; + bool layer_rollback{}; + bool origin_rollback{}; +}; + template void for_each_cascaded_declaration( const cascaded_rule_order& order, @@ -131,50 +311,99 @@ void for_each_cascaded_declaration( const auto apply_tier = [&](const auto rules, bool important) { using layer_map = std::unordered_map< cascade_layer_property, - const css_declaration*, + cascade_rollback_winner, cascade_layer_property_hash>; layer_map layer_winners; - std::unordered_map origin_winners; - std::unordered_map origin_winner_layers; + std::unordered_map layer_all_winners; + std::unordered_map origin_winners; + std::optional origin_all_winner; + size_t sequence = 0U; for (const auto* rule : rules) { for (const auto& declaration : rule->declarations()) { if (declaration.name.starts_with("--") != custom || declaration.important != important) { continue; } - const auto name = std::string_view(property_key(declaration)); - layer_winners[{rule->cascade_layer_order, name}] = &declaration; - origin_winners[name] = &declaration; - origin_winner_layers[name] = rule->cascade_layer_order; - } - } - std::unordered_set - reverted_layers; - std::unordered_set reverted_origins; - for (const auto& [layer_property, declaration] : layer_winners) { - if (cascade_keyword_is(declaration->value, "revert") - || cascade_keyword_is(declaration->value, "revert-layer")) - reverted_layers.insert(layer_property); - } - for (const auto& [name, declaration] : origin_winners) { - if (cascade_keyword_is(declaration->value, "revert") - || (cascade_keyword_is(declaration->value, "revert-layer") - && origin_winner_layers[name] == 0U)) { - reverted_origins.insert(name); + for_each_effective_box_declaration( + declaration, + [&](const css_declaration& effective, + std::string_view effective_name) { + const auto mapped_name = + std::string_view(property_key(effective)); + const auto name = mapped_name == effective.name + ? effective_name : mapped_name; + const auto revert = cascade_keyword_is( + effective.value, "revert"); + const auto revert_layer = cascade_keyword_is( + effective.value, "revert-layer"); + const cascade_rollback_winner winner{ + ++sequence, + revert || revert_layer, + revert || (revert_layer + && rule->cascade_layer_order == 0U)}; + if (!custom && name == "all") { + layer_all_winners[rule->cascade_layer_order] = winner; + origin_all_winner = winner; + } else { + layer_winners[{rule->cascade_layer_order, name}] = winner; + origin_winners[name] = winner; + } + }); } } + const auto later = [](const cascade_rollback_winner* left, + const cascade_rollback_winner* right) { + if (left == nullptr) return right; + if (right == nullptr) return left; + return left->sequence < right->sequence ? right : left; + }; + const auto layer_winner = [&](uint32_t layer, std::string_view name) { + const auto explicit_winner = layer_winners.find({layer, name}); + const auto all_winner = layer_all_winners.find(layer); + return later( + explicit_winner == layer_winners.end() + ? nullptr : &explicit_winner->second, + all_winner == layer_all_winners.end() + ? nullptr : &all_winner->second); + }; + const auto origin_winner = [&](std::string_view name) { + const auto explicit_winner = origin_winners.find(name); + return later( + explicit_winner == origin_winners.end() + ? nullptr : &explicit_winner->second, + origin_all_winner.has_value() ? &*origin_all_winner : nullptr); + }; for (const auto* rule : rules) { for (const auto& declaration : rule->declarations()) { if (declaration.name.starts_with("--") != custom || declaration.important != important) { continue; } - const auto name = std::string_view(property_key(declaration)); - if (reverted_origins.contains(name) - || reverted_layers.contains({rule->cascade_layer_order, name})) { - continue; - } - apply(declaration); + for_each_effective_box_declaration( + declaration, + [&](const css_declaration& effective, + std::string_view effective_name) { + const auto mapped_name = + std::string_view(property_key(effective)); + const auto name = mapped_name == effective.name + ? effective_name : mapped_name; + if (!custom && name == "all") { + if (!cascade_keyword_is(effective.value, "revert") + && !cascade_keyword_is( + effective.value, "revert-layer")) { + apply(effective); + } + return; + } + const auto* layer = layer_winner( + rule->cascade_layer_order, name); + const auto* origin = origin_winner(name); + if ((layer != nullptr && layer->layer_rollback) + || (origin != nullptr && origin->origin_rollback)) { + return; + } + apply(effective); + }); } } }; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_variables.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_variables.h index 60a5b0382..2b2c5eb8f 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_variables.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_variables.h @@ -98,14 +98,15 @@ inline std::string resolve_value(const dom_node& node,std::string value, inline void seed_inline_custom_properties(dom_node& node) { node.style.clear_custom_properties(); - for (const auto& [name, value] : node.authored_style().declarations) { - if (!name.starts_with("--")) continue; + node.authored_style().for_each_declaration( + [&](const std::string& name, const std::string& value) { + if (!name.starts_with("--")) return; auto& custom = node.style.mutable_custom_properties(); custom.values[name] = value; if (node.authored_style().important_declarations.contains(name)) { custom.important.insert(name); } - } + }); } inline bool apply_custom_property(dom_node& node,const css_declaration& declaration) { const auto& name=declaration.name; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h index 2629f2cd9..c1d848656 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h @@ -1064,6 +1064,51 @@ struct dom_node final { struct authored_style_data final { std::unordered_map declarations; std::unordered_set important_declarations; + // Declaration-block order matters when shorthand and longhand names + // overlap. A changed CSSOM property becomes the latest declaration. + std::vector declaration_order; + bool requires_full_replay{false}; + + void record_declaration_order(std::string_view name) + { + const auto existing = std::find( + declaration_order.begin(), declaration_order.end(), name); + if (existing != declaration_order.end()) declaration_order.erase(existing); + declaration_order.emplace_back(name); + requires_full_replay = requires_full_replay || name == "all"; + } + + void erase_declaration(std::string_view name) + { + declarations.erase(std::string(name)); + important_declarations.erase(std::string(name)); + const auto existing = std::find( + declaration_order.begin(), declaration_order.end(), name); + if (existing != declaration_order.end()) declaration_order.erase(existing); + // Removing a declaration can expose an earlier shorthand or + // longhand whose retained computed field was overwritten. + requires_full_replay = true; + } + + template + void for_each_declaration(Apply&& apply) const + { + for (const auto& name : declaration_order) { + const auto declaration = declarations.find(name); + if (declaration != declarations.end()) { + apply(declaration->first, declaration->second); + } + } + // Compatibility for internal call sites that seed declarations + // directly. Parser and CSSOM entry points always record order. + for (const auto& [name, value] : declarations) { + if (std::find( + declaration_order.begin(), declaration_order.end(), name) + == declaration_order.end()) { + apply(name, value); + } + } + } }; struct table_layout_data final { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc index 032948091..f24b8e814 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc @@ -124,7 +124,8 @@ native_document::allocation_metrics native_document::read_allocation_metrics() c result.authored_style_storage_bytes += sizeof(dom_node::authored_style_data) + hash_bytes(authored.declarations) - + hash_bytes(authored.important_declarations); + + hash_bytes(authored.important_declarations) + + authored.declaration_order.capacity() * sizeof(std::string); for (const auto& [name, value] : authored.declarations) { result.authored_style_storage_bytes += name.capacity() + value.capacity() + 2U; @@ -132,6 +133,9 @@ native_document::allocation_metrics native_document::read_allocation_metrics() c for (const auto& name : authored.important_declarations) { result.authored_style_storage_bytes += name.capacity() + 1U; } + for (const auto& name : authored.declaration_order) { + result.authored_style_storage_bytes += name.capacity() + 1U; + } } if (node != nullptr && node->has_animation_runtime()) { const auto* animation = node->animation_runtime(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_parsing.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_parsing.inc index 076fc5722..faa54266d 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_parsing.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_parsing.inc @@ -112,6 +112,7 @@ authored.declarations[declaration.name] = serialize_cssom_specified_value( declaration.name, declaration.value); + authored.record_declaration_order(declaration.name); if (declaration.important) { authored.important_declarations.insert(declaration.name); } else { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc index aa96aac59..8b3c7395d 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc @@ -34,8 +34,29 @@ { const auto canonical_name = canonical_css_property_name(name); auto& authored = node.mutable_authored_style(); - authored.declarations.erase(canonical_name); - authored.important_declarations.erase(canonical_name); + if (canonical_name == "all") { + std::vector superseded; + for (const auto& [property, ignored] : authored.declarations) { + if (!property.starts_with("--")) superseded.push_back(property); + } + for (const auto& property : superseded) { + authored.erase_declaration(property); + } + } else { + authored.erase_declaration(canonical_name); + } + uint64_t remaining_inline_mask = 0U; + authored.for_each_declaration( + [&](const std::string& property, const std::string&) { + if (!property.starts_with("--")) { + remaining_inline_mask |= inline_style_mask(property); + } + }); + struct inline_mask_refresh final { + dom_node& node; + uint64_t mask; + ~inline_mask_refresh() { node.style.inline_property_mask = mask; } + } refresh{node, remaining_inline_mask}; if (canonical_name == "grid-template-areas") { css::apply_grid_template_areas(node.style, "none"); constexpr std::array grid_properties{ @@ -364,12 +385,8 @@ static std::string serialized_inline_style(const dom_node& node) { const auto& authored = node.authored_style(); - std::vector> declarations( - authored.declarations.begin(), - authored.declarations.end()); - std::sort(declarations.begin(), declarations.end()); std::string result; - for (const auto& [name, value] : declarations) { + authored.for_each_declaration([&](const std::string& name, const std::string& value) { if (!result.empty()) result.push_back(' '); result += name; result += ": "; @@ -378,7 +395,7 @@ result += " !important"; } result.push_back(';'); - } + }); return result; } @@ -412,21 +429,18 @@ const auto has = [&](uint64_t mask) { return (node.style.inline_property_mask & mask) != 0U; }; - const auto& authored = node.authored_style().declarations; + const auto& authored = node.authored_style(); if (has(inline_padding)) { node_style specified; auto applied = false; - constexpr std::array properties{ - "padding", "padding-block", "padding-inline", - "padding-block-start", "padding-block-end", - "padding-inline-start", "padding-inline-end", - "padding-top", "padding-right", "padding-bottom", "padding-left" - }; - for (const auto* property : properties) { - const auto declaration = authored.find(property); - if (declaration == authored.end()) continue; - applied = apply_padding_declaration( - specified, property, declaration->second) || applied; + for (const auto important : {false, true}) { + authored.for_each_declaration( + [&](const std::string& property, const std::string& value) { + if (authored.important_declarations.contains(property) != important) + return; + applied = apply_padding_declaration( + specified, property, value) || applied; + }); } if (applied) { if (canonical_name == "padding-left") return serialize_length(specified.padding_left); @@ -438,17 +452,14 @@ if (has(inline_margin)) { node_style specified; auto applied = false; - constexpr std::array properties{ - "margin", "margin-block", "margin-inline", - "margin-block-start", "margin-block-end", - "margin-inline-start", "margin-inline-end", - "margin-top", "margin-right", "margin-bottom", "margin-left" - }; - for (const auto* property : properties) { - const auto declaration = authored.find(property); - if (declaration == authored.end()) continue; - applied = apply_margin_declaration( - specified, property, declaration->second) || applied; + for (const auto important : {false, true}) { + authored.for_each_declaration( + [&](const std::string& property, const std::string& value) { + if (authored.important_declarations.contains(property) != important) + return; + applied = apply_margin_declaration( + specified, property, value) || applied; + }); } if (applied) { if (canonical_name == "margin-left") return specified.margin_left_auto @@ -464,17 +475,14 @@ if (has(inline_border)) { node_style specified; auto applied = false; - constexpr std::array properties{ - "border", "border-width", - "border-top", "border-right", "border-bottom", "border-left", - "border-top-width", "border-right-width", - "border-bottom-width", "border-left-width" - }; - for (const auto* property : properties) { - const auto declaration = authored.find(property); - if (declaration == authored.end()) continue; - applied = apply_border_declaration( - specified, property, declaration->second) || applied; + for (const auto important : {false, true}) { + authored.for_each_declaration( + [&](const std::string& property, const std::string& value) { + if (authored.important_declarations.contains(property) != important) + return; + applied = apply_border_declaration( + specified, property, value) || applied; + }); } if (applied) { if (canonical_name == "border-left-width") { @@ -587,8 +595,7 @@ custom.values.erase(name); custom.important.erase(name); auto& authored = node->mutable_authored_style(); - authored.declarations.erase(name); - authored.important_declarations.erase(name); + authored.erase_declaration(name); } else { auto& custom = node->style.mutable_custom_properties(); custom.values[name] = value; @@ -596,6 +603,7 @@ else custom.important.erase(name); auto& authored = node->mutable_authored_style(); authored.declarations[name] = value; + authored.record_declaration_order(name); if (important) authored.important_declarations.insert(name); else authored.important_declarations.erase(name); } @@ -634,9 +642,16 @@ const auto existing = authored.declarations.find(canonical_name); const auto existing_important = authored.important_declarations.contains(canonical_name); + const auto all_has_superseded = canonical_name == "all" + && std::any_of( + authored.declarations.begin(), authored.declarations.end(), + [](const auto& entry) { + return entry.first != "all" && !entry.first.starts_with("--"); + }); if (existing != authored.declarations.end() && existing->second == specified_value - && existing_important == important) { + && existing_important == important + && !all_has_superseded) { #if defined(WEBSCENE_NATIVE_ENGINE_CERTIFICATION) if (self->resize_profile_active) { ++self->last_resize_redundant_style_writes; @@ -646,10 +661,24 @@ *node, "style", old_value, node->attributes.at("style")); return; } + if (canonical_name == "all") { + std::vector superseded; + for (const auto& [property, ignored] : authored.declarations) { + if (!property.starts_with("--")) superseded.push_back(property); + } + for (const auto& property : superseded) { + authored.erase_declaration(property); + } + } authored.declarations[canonical_name] = specified_value; + authored.record_declaration_order(canonical_name); if (important) authored.important_declarations.insert(canonical_name); else authored.important_declarations.erase(canonical_name); - if (value.find("var(") != std::string::npos && property_mask != 0U) { + if (canonical_name == "all") { + self->apply_css_declaration( + *node, {canonical_name, specified_value, important}, true); + node->style.inline_property_mask |= property_mask; + } else if (value.find("var(") != std::string::npos && property_mask != 0U) { // A CSSOM write is observable immediately. Resolve variable-backed // declarations through the same computed-value path used during a // cascade instead of feeding raw var() tokens to numeric parsers. @@ -1055,15 +1084,13 @@ if (computed) self->ensure_layout(); std::string value; if (!computed) { + const auto canonical_name = canonical_css_property_name(name); + const auto expanded = expanded_inline_box_longhand( + *node, canonical_name); const auto& authored_style = node->authored_style().declarations; - const auto authored = authored_style.find( - canonical_css_property_name(name)); - if (authored != authored_style.end()) value = authored->second; - else if (const auto expanded = expanded_inline_box_longhand( - *node, canonical_css_property_name(name)); - expanded.has_value()) { - value = *expanded; - } + const auto authored = authored_style.find(canonical_name); + if (expanded.has_value()) value = *expanded; + else if (authored != authored_style.end()) value = authored->second; } else if (name.starts_with("--")) { const std::string* known_value = nullptr; for (auto* current_node = node; current_node != nullptr; @@ -1279,12 +1306,18 @@ } else if (name == "border-top-style") { value = node->style.border_top_width.value > 0 ? "solid" : "none"; } else if (name == "width") { - value = computed + value = computed && node->style.width.unit == length_unit::automatic + && node->style.display == display_mode::inline_flow + ? "auto" + : computed ? format_pixels(connected_computed_dimension( *node, node->style.width, true)) : format_length(node->style.width); } else if (name == "height") { - value = computed + value = computed && node->style.height.unit == length_unit::automatic + && node->style.display == display_mode::inline_flow + ? "auto" + : computed ? format_pixels(connected_computed_dimension( *node, node->style.height, false)) : format_length(node->style.height); @@ -1514,8 +1547,7 @@ custom.values.erase(name); custom.important.erase(name); auto& authored = node->mutable_authored_style(); - authored.declarations.erase(name); - authored.important_declarations.erase(name); + authored.erase_declaration(name); sync_style_attribute(*node); self->recascade_custom_property(*node, name); self->notify_custom_element_attribute( @@ -1566,15 +1598,13 @@ if (computed) self->ensure_layout(); std::string value; if (!computed) { + const auto canonical_name = canonical_css_property_name(name); + const auto expanded = expanded_inline_box_longhand( + *node, canonical_name); const auto& authored_style = node->authored_style().declarations; - const auto authored = authored_style.find( - canonical_css_property_name(name)); - if (authored != authored_style.end()) value = authored->second; - else if (const auto expanded = expanded_inline_box_longhand( - *node, canonical_css_property_name(name)); - expanded.has_value()) { - value = *expanded; - } + const auto authored = authored_style.find(canonical_name); + if (expanded.has_value()) value = *expanded; + else if (authored != authored_style.end()) value = authored->second; info.GetReturnValue().Set(js_string(info.GetIsolate(), value.c_str())); return; } @@ -1672,12 +1702,18 @@ connected_used_value)) { value = format_pixels(connected_used_value); } else if (name == "width") { - value = computed + value = computed && node->style.width.unit == length_unit::automatic + && node->style.display == display_mode::inline_flow + ? "auto" + : computed ? format_pixels(connected_computed_dimension( *node, node->style.width, true)) : format_length(node->style.width); } else if (name == "height") { - value = computed + value = computed && node->style.height.unit == length_unit::automatic + && node->style.display == display_mode::inline_flow + ? "auto" + : computed ? format_pixels(connected_computed_dimension( *node, node->style.height, false)) : format_length(node->style.height); @@ -2195,9 +2231,16 @@ canonical_name, value); const auto existing = authored.declarations.find(canonical_name); + const auto all_has_superseded = canonical_name == "all" + && std::any_of( + authored.declarations.begin(), authored.declarations.end(), + [](const auto& entry) { + return entry.first != "all" && !entry.first.starts_with("--"); + }); if (existing != authored.declarations.end() && existing->second == specified_value - && !authored.important_declarations.contains(canonical_name)) { + && !authored.important_declarations.contains(canonical_name) + && !all_has_superseded) { #if defined(WEBSCENE_NATIVE_ENGINE_CERTIFICATION) if (self->resize_profile_active) { ++self->last_resize_redundant_style_writes; @@ -2207,9 +2250,23 @@ *node, "style", old_value, node->attributes.at("style")); return; } + if (canonical_name == "all") { + std::vector superseded; + for (const auto& [property, ignored] : authored.declarations) { + if (!property.starts_with("--")) superseded.push_back(property); + } + for (const auto& property : superseded) { + authored.erase_declaration(property); + } + } authored.declarations[canonical_name] = specified_value; + authored.record_declaration_order(canonical_name); authored.important_declarations.erase(canonical_name); - if (canonical_name == "grid-template-areas") { + if (canonical_name == "all") { + self->apply_css_declaration( + *node, {canonical_name, specified_value, false}, true); + node->style.inline_property_mask |= property_mask; + } else if (canonical_name == "grid-template-areas") { css::apply_grid_template_areas(node->style, value); node->style.inline_property_mask |= inline_grid; } else if (apply_grid_placement_declaration(node->style, name, value)) { diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_css_invalidation_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_css_invalidation_tests.inc index ae56d92c0..f37abdea1 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_css_invalidation_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_css_invalidation_tests.inc @@ -243,20 +243,30 @@ void test_cascade_layer_mutation_scaling() Array.from({length:UNRELATED},(_,i)=>'
').join('')+''; const style=document.createElement('style'); style.textContent='@layer reset, components;'+ - '@layer reset {.layer-target {display:block;width:11px;height:13px!important}}'+ - '@layer components {#layer-scope.active .layer-target {width:41px;height:43px!important}}'+ + '@layer reset {.layer-target {display:block;width:11px;height:13px!important;'+ + 'margin:1px 2px 3px 4px;padding:5px 6px 7px 8px}}'+ + '@layer components {#layer-scope.active .layer-target {'+ + 'width:41px;height:43px!important;margin:20px 21px 22px 23px;'+ + 'margin-left:revert-layer;padding:30px 31px 32px 33px;'+ + 'padding-right:revert-layer}}'+ Array.from({length:UNRELATED},(_,i)=> '@layer noise-'+i+' {.layer-noise-'+i+' {width:7px}}').join(''); document.head.appendChild(style); const scope=document.getElementById('layer-scope'); const targets=Array.from(document.querySelectorAll('.layer-target')); - const snapshot=width=>targets.every(node=> - getComputedStyle(node).width===width && getComputedStyle(node).height==='13px'); + const snapshot=enabled=>targets.every(node=>{ + const computed=getComputedStyle(node); + return computed.width===(enabled?'41px':'11px') && + computed.height==='13px' && computed.marginLeft==='4px' && + computed.marginTop===(enabled?'20px':'1px') && + computed.paddingRight==='6px' && + computed.paddingTop===(enabled?'30px':'5px'); + }); globalThis.mutateLayerScope=enabled=>{ scope.classList.toggle('active',enabled); - return snapshot(enabled?'41px':'11px'); + return snapshot(enabled); }; - return snapshot('11px'); + return snapshot(false); })())JS"}; source.replace(source.find("AFFECTED"), 8, std::to_string(affected_count)); for (size_t at = source.find("UNRELATED"); at != std::string::npos; diff --git a/tests/WebPlatformSubset/contracts/css-effective-property-cascade.html b/tests/WebPlatformSubset/contracts/css-effective-property-cascade.html new file mode 100644 index 000000000..5b5bfb4a2 --- /dev/null +++ b/tests/WebPlatformSubset/contracts/css-effective-property-cascade.html @@ -0,0 +1,233 @@ + + +Effective-property shorthand, all, rollback, and inline-order cascade + + + + +
+
+
+
+
+
font
+
+
+
+
+
+
+ + diff --git a/tests/WebPlatformSubset/webscene-component-profile.json b/tests/WebPlatformSubset/webscene-component-profile.json index 3b6a9ee52..ada8e1f6c 100644 --- a/tests/WebPlatformSubset/webscene-component-profile.json +++ b/tests/WebPlatformSubset/webscene-component-profile.json @@ -934,6 +934,13 @@ "evidence": ["css-cascade-matrix-audit"], "reason": "Table-driven browser/native cascade winner and computed-value checkpoints for #238." }, + { + "path": "contracts/css-effective-property-cascade.html", + "type": "testharness", + "capabilities": ["effective-property-cascade", "shorthand-longhand-precedence", "cascade-rollback", "all-property", "inline-declaration-order"], + "evidence": ["css-effective-property-audit"], + "reason": "Browser-referenced effective-longhand cascade coverage for shorthand rollback, all, and inline CSSOM authored order." + }, { "path": "contracts/css-character-data-checkpoints.html", "type": "testharness",