From eaaaa4e830ec02610e893b3cfc97348e932e2787 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Mon, 17 Aug 2026 03:20:11 +0200 Subject: [PATCH] LibWeb: Materialize input shadow trees on demand Creating an input or changing its type eagerly built its rendering-only user-agent shadow tree. HTML fragment parsing consequently created full internal trees for controls in detached, short-lived subtrees. Prepare newly connected elements before their first style transaction so input rendering descendants join the initial control transaction. This avoids feedback restyles and preserves laziness for detached trees. Rebuild the tree immediately when a styled control changes type. Keep input state independent from rendering nodes, including selection and :placeholder-shown. Add coverage for typed and image inputs, selector matching before style computation, and the resulting style transaction behavior. --- Libraries/LibWeb/CSS/StyleComputer.cpp | 14 ++++++++ Libraries/LibWeb/CSS/StyleComputer.h | 1 + Libraries/LibWeb/CSS/StyleEngineBridge.cpp | 5 +++ Libraries/LibWeb/CSS/StyleEngineBridge.h | 14 ++++++-- Libraries/LibWeb/CSS/UpdateStyle.cpp | 4 +++ Libraries/LibWeb/DOM/Element.cpp | 2 +- Libraries/LibWeb/DOM/Element.h | 3 ++ Libraries/LibWeb/HTML/HTMLInputElement.cpp | 20 +++++++---- Libraries/LibWeb/HTML/HTMLInputElement.h | 3 ++ Libraries/LibWeb/Internals/Internals.cpp | 8 +++++ Libraries/LibWeb/Internals/Internals.h | 1 + Libraries/LibWeb/Internals/Internals.idl | 2 ++ .../DOM/input-shadow-tree-materialization.txt | 7 ++++ .../css/style-engine/has-mutations.txt | 2 +- .../input-placeholder-shown-before-style.txt | 6 ++++ .../input-shadow-tree-materialization.html | 36 +++++++++++++++++++ .../input/css/style-engine/has-mutations.html | 4 ++- .../input-placeholder-shown-before-style.html | 21 +++++++++++ 18 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 Tests/LibWeb/Text/expected/DOM/input-shadow-tree-materialization.txt create mode 100644 Tests/LibWeb/Text/expected/input-placeholder-shown-before-style.txt create mode 100644 Tests/LibWeb/Text/input/DOM/input-shadow-tree-materialization.html create mode 100644 Tests/LibWeb/Text/input/input-placeholder-shown-before-style.html diff --git a/Libraries/LibWeb/CSS/StyleComputer.cpp b/Libraries/LibWeb/CSS/StyleComputer.cpp index 81adfe53559b1..4d60e1d47412c 100644 --- a/Libraries/LibWeb/CSS/StyleComputer.cpp +++ b/Libraries/LibWeb/CSS/StyleComputer.cpp @@ -214,6 +214,20 @@ GC::Ptr StyleComputer::element_for_style_node(StyleNodeID style_no return m_style_nodes[style_node_id.value()]; } +void StyleComputer::prepare_elements_for_style_computation() +{ + for (;;) { + auto elements = m_style_engine.take_elements_awaiting_first_style_computation(); + if (elements.is_empty()) + break; + for (auto style_node : elements) { + auto element = element_for_style_node(style_node); + if (element && element->is_connected()) + element->prepare_for_style_computation({}); + } + } +} + void StyleComputer::visit_edges(Visitor& visitor) { Base::visit_edges(visitor); diff --git a/Libraries/LibWeb/CSS/StyleComputer.h b/Libraries/LibWeb/CSS/StyleComputer.h index c7ffba4cb6a43..db56891f34432 100644 --- a/Libraries/LibWeb/CSS/StyleComputer.h +++ b/Libraries/LibWeb/CSS/StyleComputer.h @@ -350,6 +350,7 @@ class WEB_API StyleComputer final : public GC::Cell { void register_style_node(StyleNodeID style_node_id, DOM::Element&); void unregister_style_node(StyleNodeID style_node_id); [[nodiscard]] GC::Ptr element_for_style_node(StyleNodeID style_node_id) const; + void prepare_elements_for_style_computation(); // Style scopes are numbered per document, with zero naming the document's own scope. A scope is // never reused, so a sheet detached with an identity that has been retired detaches nothing diff --git a/Libraries/LibWeb/CSS/StyleEngineBridge.cpp b/Libraries/LibWeb/CSS/StyleEngineBridge.cpp index 99acc5b351272..0a19eecc8639a 100644 --- a/Libraries/LibWeb/CSS/StyleEngineBridge.cpp +++ b/Libraries/LibWeb/CSS/StyleEngineBridge.cpp @@ -72,6 +72,11 @@ Vector StyleEngine::take_deferred_element_initial_features() return nodes; } +HashTable StyleEngine::take_elements_awaiting_first_style_computation() +{ + return move(m_nodes_awaiting_first_style_computation); +} + bool StyleEngine::resize_parsed_substitution_cache(u64 bytes) { return StyleEngineFFI::style_engine_resize_parsed_substitution_cache(m_impl, bytes); diff --git a/Libraries/LibWeb/CSS/StyleEngineBridge.h b/Libraries/LibWeb/CSS/StyleEngineBridge.h index 4b4582edb1913..f6afe79a044eb 100644 --- a/Libraries/LibWeb/CSS/StyleEngineBridge.h +++ b/Libraries/LibWeb/CSS/StyleEngineBridge.h @@ -50,10 +50,19 @@ class WEB_API StyleEngine { // Identity 0 is never returned; it means "no node". StyleNodeID allocate_style_node(); void allocate_style_nodes(Span nodes); - void defer_element_initial_features(StyleNodeID style_node) { m_nodes_with_pending_initial_features.set(style_node); } - void cancel_deferred_element_initial_features(StyleNodeID style_node) { m_nodes_with_pending_initial_features.remove(style_node); } + void defer_element_initial_features(StyleNodeID style_node) + { + m_nodes_with_pending_initial_features.set(style_node); + m_nodes_awaiting_first_style_computation.set(style_node); + } + void cancel_deferred_element_initial_features(StyleNodeID style_node) + { + m_nodes_with_pending_initial_features.remove(style_node); + m_nodes_awaiting_first_style_computation.remove(style_node); + } [[nodiscard]] bool has_deferred_element_initial_features(StyleNodeID style_node) const { return m_nodes_with_pending_initial_features.contains(style_node); } Vector take_deferred_element_initial_features(); + HashTable take_elements_awaiting_first_style_computation(); [[nodiscard]] bool resize_parsed_substitution_cache(u64 bytes); void set_element_parts(StyleNodeID node, ReadonlySpan names, ReadonlySpan hosts); @@ -223,6 +232,7 @@ class WEB_API StyleEngine { HashTable m_atoms; HashTable m_nodes_with_pending_initial_features; + HashTable m_nodes_awaiting_first_style_computation; size_t m_element_match_capacity { 64 }; u32 m_declaration_block_version { 1 }; diff --git a/Libraries/LibWeb/CSS/UpdateStyle.cpp b/Libraries/LibWeb/CSS/UpdateStyle.cpp index d3ab026576977..45730effebda3 100644 --- a/Libraries/LibWeb/CSS/UpdateStyle.cpp +++ b/Libraries/LibWeb/CSS/UpdateStyle.cpp @@ -454,6 +454,10 @@ static void update_style(DOM::Document& document) // Fetch the viewport rect once, instead of repeatedly, during style computation. document.update_style_computer_viewport_rect(); + // An element may have rendering-only descendants that must join the transaction which first styles it. Prepare + // those descendants before selector inputs cross the transaction boundary. + document.style_computer().prepare_elements_for_style_computation(); + // Media rules are evaluated before the transaction boundary below, because evaluating them is // itself a source of inputs: a rule that starts or stops applying publishes its activation. A // transaction taken ahead of that would leave those inputs for the next flush, so the flush that made diff --git a/Libraries/LibWeb/DOM/Element.cpp b/Libraries/LibWeb/DOM/Element.cpp index c5c5dc8344eea..a745b6c829d78 100644 --- a/Libraries/LibWeb/DOM/Element.cpp +++ b/Libraries/LibWeb/DOM/Element.cpp @@ -2690,7 +2690,7 @@ bool Element::matches_placeholder_shown_pseudo_class() const // - input elements that have a placeholder attribute whose value is currently being presented to the user. if (is(*this) && has_attribute(HTML::AttributeNames::placeholder)) { auto const& input_element = static_cast(*this); - return input_element.placeholder_element() && input_element.placeholder_value().has_value(); + return input_element.placeholder_value().has_value(); } // - textarea elements that have a placeholder attribute whose value is currently being presented to the user. if (is(*this) && has_attribute(HTML::AttributeNames::placeholder)) { diff --git a/Libraries/LibWeb/DOM/Element.h b/Libraries/LibWeb/DOM/Element.h index e99790019e7f7..fb7f66f779112 100644 --- a/Libraries/LibWeb/DOM/Element.h +++ b/Libraries/LibWeb/DOM/Element.h @@ -777,6 +777,8 @@ class WEB_API Element virtual void initialize_element() { } + void prepare_for_style_computation(Badge) { prepare_for_style_computation(); } + protected: Element(Document&, DOM::QualifiedName); @@ -791,6 +793,7 @@ class WEB_API Element MUST_UPCALL virtual void attribute_changed(Utf16FlyString const& local_name, Optional const& old_value, Optional const& value, Optional const& namespace_); virtual void computed_properties_changed() { } + virtual void prepare_for_style_computation() { } virtual void visit_edges(Cell::Visitor&) override; diff --git a/Libraries/LibWeb/HTML/HTMLInputElement.cpp b/Libraries/LibWeb/HTML/HTMLInputElement.cpp index 68118da106d9a..db21ad96e2a06 100644 --- a/Libraries/LibWeb/HTML/HTMLInputElement.cpp +++ b/Libraries/LibWeb/HTML/HTMLInputElement.cpp @@ -799,10 +799,11 @@ WebIDL::ExceptionOr HTMLInputElement::set_value(Utf16View value) if (m_text_node) { m_text_node->set_data(m_value); update_placeholder_visibility(); - - set_the_selection_range(m_text_node->length(), m_text_node->length()); } + if (selection_or_range_applies()) + set_the_selection_range(m_value.length_in_code_units(), m_value.length_in_code_units()); + update_shadow_tree(); } @@ -963,7 +964,7 @@ void HTMLInputElement::update_text_input_shadow_tree() { update_placeholder_visibility(); - if (m_type == TypeAttributeState::Number) { + if (m_type == TypeAttributeState::Number && m_up_button_element && m_down_button_element) { // The `textfield` appearance is used to hide the stepper buttons. if (auto style = computed_style(); style && style->appearance() == CSS::Appearance::Textfield) { m_up_button_element->set_inline_style(stepper_button_style_when_hidden()); @@ -1064,7 +1065,7 @@ Utf16String HTMLInputElement::placeholder() const // https://html.spec.whatwg.org/multipage/input.html#attr-input-placeholder Optional HTMLInputElement::placeholder_value() const { - if (!m_text_node || !m_text_node->data().is_empty()) + if (!relevant_value().is_empty()) return {}; if (!is_allowed_to_have_placeholder(type_state())) return {}; @@ -1175,6 +1176,9 @@ void HTMLInputElement::remove_image_button_alt_text_shadow_tree() void HTMLInputElement::update_image_button_alt_text_shadow_tree() { + if (!shadow_root() && !has_style()) + return; + auto alt_text = get_attribute_value(HTML::AttributeNames::alt); if (type_state() != TypeAttributeState::ImageButton || !renders_as_alt_text() || alt_text.is_empty()) { remove_image_button_alt_text_shadow_tree(); @@ -1745,9 +1749,11 @@ void HTMLInputElement::type_attribute_changed(TypeAttributeState old_state, Type CSS::Invalidation::invalidate_style_after_default_state_change(*this, was_default); CSS::Invalidation::invalidate_style_after_read_write_state_change(*this, was_read_write); clear_element_reference_pseudo_elements(); + auto should_materialize_shadow_tree = shadow_root() || has_style(); set_shadow_root(nullptr); m_image_button_alt_text_node = nullptr; - create_shadow_tree_if_needed(); + if (should_materialize_shadow_tree) + create_shadow_tree_if_needed(); // 5. Signal a type change for the element. (The Radio Button state uses this, in particular.) signal_a_type_change(); @@ -2164,7 +2170,9 @@ void HTMLInputElement::clear_algorithm() void HTMLInputElement::form_associated_element_was_inserted() { - create_shadow_tree_if_needed(); + // NB: The user-agent shadow tree is rendering state. It is created when a connected control first participates in + // a style update, before computed properties are assigned. Creating it in the insertion steps would also + // materialize controls in detached and short-lived trees. if (is_connected()) { // https://html.spec.whatwg.org/multipage/input.html#radio-button-state-(type=radio) diff --git a/Libraries/LibWeb/HTML/HTMLInputElement.h b/Libraries/LibWeb/HTML/HTMLInputElement.h index c1737d2b8f9c8..04c7ae5005da0 100644 --- a/Libraries/LibWeb/HTML/HTMLInputElement.h +++ b/Libraries/LibWeb/HTML/HTMLInputElement.h @@ -106,6 +106,8 @@ class WEB_API HTMLInputElement final void commit_pending_changes(); bool has_uncommitted_changes() { return m_has_uncommitted_changes; } + void ensure_user_agent_shadow_tree(Badge) { create_shadow_tree_if_needed(); } + Utf16String placeholder() const; Optional placeholder_value() const; @@ -281,6 +283,7 @@ class WEB_API HTMLInputElement final void type_attribute_changed(TypeAttributeState old_state, TypeAttributeState new_state); virtual void computed_properties_changed() override; + virtual void prepare_for_style_computation() override { create_shadow_tree_if_needed(); } virtual bool is_presentational_hint(Utf16FlyString const&) const override; virtual void apply_presentational_hints(Vector&) const override; diff --git a/Libraries/LibWeb/Internals/Internals.cpp b/Libraries/LibWeb/Internals/Internals.cpp index d221019b6e8de..6d68b28ec3b6d 100644 --- a/Libraries/LibWeb/Internals/Internals.cpp +++ b/Libraries/LibWeb/Internals/Internals.cpp @@ -62,6 +62,7 @@ #include #include #include +#include #include #include #include @@ -972,8 +973,15 @@ GC::Ref Internals::flush_session_history_traversal_queue() return promise; } +bool Internals::has_shadow_root(GC::Ref element) +{ + return element->shadow_root() != nullptr; +} + GC::Ptr Internals::get_shadow_root(GC::Ref element) { + if (auto* input = as_if(*element)) + input->ensure_user_agent_shadow_tree({}); return element->shadow_root(); } diff --git a/Libraries/LibWeb/Internals/Internals.h b/Libraries/LibWeb/Internals/Internals.h index b2df9d5c9e051..c30963daed54d 100644 --- a/Libraries/LibWeb/Internals/Internals.h +++ b/Libraries/LibWeb/Internals/Internals.h @@ -148,6 +148,7 @@ class WEB_API Internals final : public InternalsBase { bool has_html_parser_end_state(DOM::Document& document) { return document.has_html_parser_end_state(); } void clobber_next_navigation_with_a_traversal(); + bool has_shadow_root(GC::Ref); GC::Ptr get_shadow_root(GC::Ref); void handle_sdl_input_events(); diff --git a/Libraries/LibWeb/Internals/Internals.idl b/Libraries/LibWeb/Internals/Internals.idl index e2634eadcac82..71c463a007ed4 100644 --- a/Libraries/LibWeb/Internals/Internals.idl +++ b/Libraries/LibWeb/Internals/Internals.idl @@ -129,6 +129,8 @@ interface Internals { // re-stamping the next navigation's ongoing navigation with a traversal during its unload check. undefined clobberNextNavigationWithATraversal(); + boolean hasShadowRoot(Element element); + // Returns the shadow root of the element, if it has one, even if it's not normally accessible to JS. ShadowRoot? getShadowRoot(Element element); diff --git a/Tests/LibWeb/Text/expected/DOM/input-shadow-tree-materialization.txt b/Tests/LibWeb/Text/expected/DOM/input-shadow-tree-materialization.txt new file mode 100644 index 0000000000000..42457b478154a --- /dev/null +++ b/Tests/LibWeb/Text/expected/DOM/input-shadow-tree-materialization.txt @@ -0,0 +1,7 @@ +detached parsed input: false +detached type change: false +detached number value change: false +detached parsed image input: false +detached image alt change: false +after style computation: true +materialized type change: true diff --git a/Tests/LibWeb/Text/expected/css/style-engine/has-mutations.txt b/Tests/LibWeb/Text/expected/css/style-engine/has-mutations.txt index 0eb3f294d18df..80bd7ec2f8473 100644 --- a/Tests/LibWeb/Text/expected/css/style-engine/has-mutations.txt +++ b/Tests/LibWeb/Text/expected/css/style-engine/has-mutations.txt @@ -20,5 +20,5 @@ the first alternative of a listed argument: [listed-alternatives, listed-hit] the second alternative of a listed argument: [listed-alternatives-2, listed-hit-2] an argument that names no fact at all: [anything, anything-child] an argument that names a position: [positional-last] -an argument that names a constraint: [constrained, constrained-input, div, div, div] +an argument that names a constraint: [constrained, constrained-input] a sibling seam closing over a departure: [seam-after] diff --git a/Tests/LibWeb/Text/expected/input-placeholder-shown-before-style.txt b/Tests/LibWeb/Text/expected/input-placeholder-shown-before-style.txt new file mode 100644 index 0000000000000..8d63d27c2c5ca --- /dev/null +++ b/Tests/LibWeb/Text/expected/input-placeholder-shown-before-style.txt @@ -0,0 +1,6 @@ +parsed input: true +appended input: true +shadow before value: false +non-empty value: false +shadow after value: false +cleared value: true diff --git a/Tests/LibWeb/Text/input/DOM/input-shadow-tree-materialization.html b/Tests/LibWeb/Text/input/DOM/input-shadow-tree-materialization.html new file mode 100644 index 0000000000000..e096604d1fc88 --- /dev/null +++ b/Tests/LibWeb/Text/input/DOM/input-shadow-tree-materialization.html @@ -0,0 +1,36 @@ + + + diff --git a/Tests/LibWeb/Text/input/css/style-engine/has-mutations.html b/Tests/LibWeb/Text/input/css/style-engine/has-mutations.html index afce26fb913df..756957b789cbb 100644 --- a/Tests/LibWeb/Text/input/css/style-engine/has-mutations.html +++ b/Tests/LibWeb/Text/input/css/style-engine/has-mutations.html @@ -124,7 +124,9 @@ plan("an argument that names a position", () => document.getElementById("positional").appendChild(make("span", "positional-last"))); - // A control arrives already required, which no attribute the engine indexes says. + // This control is already required before the mutation, so no indexed attribute mutation represents that + // initial state. Rendering-only user-agent descendants remain unmaterialized until style is computed, so this + // mutation publishes only author nodes. plan("an argument that names a constraint", () => document.getElementById("constrained").appendChild(make("input", "constrained-input", { attributes: { required: "" } }))); diff --git a/Tests/LibWeb/Text/input/input-placeholder-shown-before-style.html b/Tests/LibWeb/Text/input/input-placeholder-shown-before-style.html new file mode 100644 index 0000000000000..9b14a3f3e2058 --- /dev/null +++ b/Tests/LibWeb/Text/input/input-placeholder-shown-before-style.html @@ -0,0 +1,21 @@ + + + +