LibWeb: Refresh blocker CSS after relevant class/id changes

Refresh content blocker styles when connected elements gain or change
class or id tokens that can affect generic cosmetic selectors. Cache the
class and id tokens covered by each document's content blocker
stylesheet, then ask the adblock engine whether newly-added tokens can
unlock selectors before invalidating user style for the whole page.

This keeps dynamic cosmetic hiding correct without refreshing blocker
CSS for unrelated class and id mutations.

Add text coverage for irrelevant class/id mutations that should preserve
author style invalidation without refreshing blocker CSS. Also prove
matching tokens still hide elements.
This commit is contained in:
Andreas Kling 2026-05-23 22:28:30 +02:00 committed by Andreas Kling
parent 96bad93bb2
commit 6ea2a4eff5
9 changed files with 236 additions and 27 deletions

View file

@ -267,6 +267,31 @@ impl GenericSelectorListRules {
selectors
}
fn has_hidden_selectors_for_class_or_id(
&self,
classes: impl IntoIterator<Item = impl AsRef<str>>,
ids: impl IntoIterator<Item = impl AsRef<str>>,
exceptions: &HashSet<String>,
) -> bool {
for class in classes {
if let Some(class_selectors) = self.by_class.get(class.as_ref())
&& class_selectors.iter().any(|selector| !exceptions.contains(selector))
{
return true;
}
}
for id in ids {
if let Some(id_selectors) = self.by_id.get(id.as_ref())
&& id_selectors.iter().any(|selector| !exceptions.contains(selector))
{
return true;
}
}
false
}
}
fn cosmetic_css_for_url(engine: &ContentBlockerEngine, url: &str, classes: &[&str], ids: &[&str]) -> String {
@ -317,6 +342,28 @@ fn cosmetic_css_for_url(engine: &ContentBlockerEngine, url: &str, classes: &[&st
css
}
fn has_generic_cosmetic_selectors_for_url(
engine: &ContentBlockerEngine,
url: &str,
classes: &[&str],
ids: &[&str],
) -> bool {
let resources = engine.engine.url_cosmetic_resources(url);
if resources.generichide {
return false;
}
!engine
.engine
.hidden_class_id_selectors(classes.iter().copied(), ids.iter().copied(), &resources.exceptions)
.is_empty()
|| engine.generic_selector_list_rules.has_hidden_selectors_for_class_or_id(
classes.iter().copied(),
ids.iter().copied(),
&resources.exceptions,
)
}
/// # Safety
/// - `rules` and `rules_len` must point to a valid UTF-8 string
/// - The returned pointer must be freed with `rust_content_blocker_free`
@ -417,6 +464,39 @@ pub unsafe extern "C" fn rust_content_blocker_cosmetic_css(
})
}
/// # Safety
/// - `engine` must be null or a valid pointer returned by `rust_content_blocker_create`
/// - String pointers and lengths must point to valid UTF-8 strings
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rust_content_blocker_has_generic_cosmetic_selectors(
engine: *const c_void,
url: *const u8,
url_len: usize,
classes: *const u8,
classes_len: usize,
ids: *const u8,
ids_len: usize,
) -> bool {
abort_on_panic(|| {
let Some(engine) = (unsafe { engine_from_raw(engine) }) else {
return false;
};
let Some(url) = (unsafe { string_from_raw(url, url_len) }) else {
return false;
};
let Some(classes) = (unsafe { string_from_raw(classes, classes_len) }) else {
return false;
};
let Some(ids) = (unsafe { string_from_raw(ids, ids_len) }) else {
return false;
};
let classes: Vec<_> = classes.lines().collect();
let ids: Vec<_> = ids.lines().collect();
has_generic_cosmetic_selectors_for_url(engine, url, &classes, &ids)
})
}
/// # Safety
/// - `data` and `length` must match a string returned by `rust_content_blocker_cosmetic_css`
#[unsafe(no_mangle)]

View file

@ -775,14 +775,68 @@ void Document::visit_edges(Cell::Visitor& visitor)
String const& Document::content_blocker_style_sheet()
{
if (!m_content_blocker_style_sheet.has_value())
m_content_blocker_style_sheet = ContentBlocker::the().cosmetic_style_sheet_for_document(*this);
if (!m_content_blocker_style_sheet.has_value()) {
m_content_blocker_style_sheet_checked_classes.clear();
m_content_blocker_style_sheet_checked_ids.clear();
Vector<String> classes;
Vector<String> ids;
for_each_shadow_including_descendant([&](DOM::Node& node) {
auto* element = as_if<DOM::Element>(node);
if (!element)
return TraversalDecision::Continue;
if (auto const& id = element->id(); id.has_value()) {
auto id_string = id->to_string();
if (!id_string.is_empty() && m_content_blocker_style_sheet_checked_ids.set(*id) == AK::HashSetResult::InsertedNewEntry)
ids.append(move(id_string));
}
for (auto const& class_name : element->class_names()) {
auto class_string = class_name.to_string();
if (!class_string.is_empty() && m_content_blocker_style_sheet_checked_classes.set(class_name) == AK::HashSetResult::InsertedNewEntry)
classes.append(move(class_string));
}
return TraversalDecision::Continue;
});
m_content_blocker_style_sheet = ContentBlocker::the().cosmetic_style_sheet_for_url(fallback_base_url(), classes, ids);
}
return m_content_blocker_style_sheet.value();
}
void Document::invalidate_content_blocker_style_sheet()
{
m_content_blocker_style_sheet.clear();
m_content_blocker_style_sheet_checked_classes.clear();
m_content_blocker_style_sheet_checked_ids.clear();
}
bool Document::content_blocker_style_sheet_may_need_refresh_for_class_or_id(FlyString const* id, ReadonlySpan<FlyString> class_names)
{
if (!m_content_blocker_style_sheet.has_value())
return false;
Vector<String> classes_to_check;
Vector<String> ids_to_check;
auto append_new_token = [](FlyString const& token, HashTable<FlyString>& checked_tokens, Vector<String>& tokens_to_check) {
auto token_string = token.to_string();
if (!token_string.is_empty() && checked_tokens.set(token) == AK::HashSetResult::InsertedNewEntry)
tokens_to_check.append(move(token_string));
};
if (id)
append_new_token(*id, m_content_blocker_style_sheet_checked_ids, ids_to_check);
for (auto const& class_name : class_names)
append_new_token(class_name, m_content_blocker_style_sheet_checked_classes, classes_to_check);
if (classes_to_check.is_empty() && ids_to_check.is_empty())
return false;
return ContentBlocker::the().has_generic_cosmetic_selectors_for_url(fallback_base_url(), classes_to_check, ids_to_check);
}
// https://w3c.github.io/selection-api/#dom-document-getselection

View file

@ -10,6 +10,7 @@
#pragma once
#include <AK/FlyString.h>
#include <AK/Function.h>
#include <AK/HashMap.h>
#include <AK/HashTable.h>
@ -1099,6 +1100,7 @@ public:
CSS::StyleScope& style_scope() { return m_style_scope; }
String const& content_blocker_style_sheet();
void invalidate_content_blocker_style_sheet();
bool content_blocker_style_sheet_may_need_refresh_for_class_or_id(FlyString const* id, ReadonlySpan<FlyString> class_names);
void exit_pointer_lock();
@ -1469,6 +1471,9 @@ private:
ShadowRoot::DocumentShadowRootList m_shadow_roots;
Optional<String> m_content_blocker_style_sheet;
// Class/id tokens already covered by the cached content blocker stylesheet.
HashTable<FlyString> m_content_blocker_style_sheet_checked_classes;
HashTable<FlyString> m_content_blocker_style_sheet_checked_ids;
Optional<AK::UnixDateTime> m_last_modified;

View file

@ -110,6 +110,7 @@
#include <LibWeb/Layout/ListItemBox.h>
#include <LibWeb/Layout/TreeBuilder.h>
#include <LibWeb/Layout/Viewport.h>
#include <LibWeb/Loader/ContentBlocker.h>
#include <LibWeb/MathML/MathMLElement.h>
#include <LibWeb/MathML/TagNames.h>
#include <LibWeb/Namespace.h>
@ -133,6 +134,20 @@ namespace Web::DOM {
GC_DEFINE_ALLOCATOR(Element);
static void invalidate_content_blocker_style_if_needed(Element& element)
{
if (!element.is_connected())
return;
if (!ContentBlocker::the().filtering_enabled() || !ContentBlocker::the().has_cosmetic_rules())
return;
auto const& id = element.id();
if (!element.document().content_blocker_style_sheet_may_need_refresh_for_class_or_id(id.has_value() ? &id.value() : nullptr, element.class_names()))
return;
element.document().page().invalidate_user_style();
}
Element::Element(Document& document, DOM::QualifiedName qualified_name)
: ParentNode(document, NodeType::ELEMENT_NODE)
, m_qualified_name(move(qualified_name))
@ -862,6 +877,8 @@ void Element::run_attribute_change_steps(FlyString const& local_name, Optional<S
if (old_value != value) {
CSS::Invalidation::invalidate_style_after_attribute_change(*this, local_name, old_value, value);
if (local_name == HTML::AttributeNames::id || local_name == HTML::AttributeNames::class_)
invalidate_content_blocker_style_if_needed(*this);
document().bump_dom_tree_version();
}
}
@ -1708,6 +1725,8 @@ void Element::inserted()
document().element_with_id_was_added({}, *this);
if (m_name.has_value())
document().element_with_name_was_added({}, *this);
if (m_id.has_value() || !m_classes.is_empty())
invalidate_content_blocker_style_if_needed(*this);
}
play_or_cancel_animations_after_display_property_change();

View file

@ -9,8 +9,6 @@
#include <AK/Vector.h>
#include <LibURL/Parser.h>
#include <LibWeb/ContentBlockerRustFFI.h>
#include <LibWeb/DOM/Document.h>
#include <LibWeb/DOM/Element.h>
#include <LibWeb/Loader/ContentBlocker.h>
namespace Web {
@ -232,32 +230,31 @@ String ContentBlocker::cosmetic_style_sheet_for_url(URL::URL const& url, Readonl
ids_bytes.length()));
}
String ContentBlocker::cosmetic_style_sheet_for_document(DOM::Document const& document) const
bool ContentBlocker::has_generic_cosmetic_selectors_for_url(URL::URL const& url, ReadonlySpan<String> classes, ReadonlySpan<String> ids) const
{
if (!filtering_enabled() || !m_engine || !m_has_cosmetic_rules)
return {};
return false;
Vector<String> classes;
Vector<String> ids;
const_cast<DOM::Document&>(document).for_each_shadow_including_descendant([&](DOM::Node& node) {
auto* element = as_if<DOM::Element>(node);
if (!element)
return TraversalDecision::Continue;
auto url_string = serialized_url(url);
auto classes_string = join_lines(classes);
if (classes_string.is_error())
return false;
if (auto const& id = element->id(); id.has_value()) {
if (auto id_string = id->to_string(); !id_string.is_empty())
ids.append(move(id_string));
}
auto ids_string = join_lines(ids);
if (ids_string.is_error())
return false;
for (auto const& class_name : element->class_names()) {
if (auto class_string = class_name.to_string(); !class_string.is_empty())
classes.append(move(class_string));
}
auto classes_bytes = classes_string.value().bytes_as_string_view();
auto ids_bytes = ids_string.value().bytes_as_string_view();
return TraversalDecision::Continue;
});
return cosmetic_style_sheet_for_url(document.fallback_base_url(), classes, ids);
return ContentBlocking::FFI::rust_content_blocker_has_generic_cosmetic_selectors(
m_engine,
reinterpret_cast<u8 const*>(url_string.characters()),
url_string.length(),
reinterpret_cast<u8 const*>(classes_bytes.characters_without_null_termination()),
classes_bytes.length(),
reinterpret_cast<u8 const*>(ids_bytes.characters_without_null_termination()),
ids_bytes.length());
}
ContentBlocker::ResourceType ContentBlocker::resource_type_from_fetch_metadata(Optional<Fetch::Infrastructure::Request::Destination> const& destination, Optional<Fetch::Infrastructure::Request::InitiatorType> const& initiator_type, Fetch::Infrastructure::Request::Mode mode)

View file

@ -13,7 +13,6 @@
#include <LibURL/URL.h>
#include <LibWeb/Export.h>
#include <LibWeb/Fetch/Infrastructure/HTTP/Requests.h>
#include <LibWeb/Forward.h>
namespace Web {
@ -50,9 +49,9 @@ public:
ErrorOr<void> set_patterns(ReadonlySpan<String>);
ErrorOr<void> set_rules_from_bytes(ReadonlyBytes);
String cosmetic_style_sheet_for_document(DOM::Document const&) const;
String cosmetic_style_sheet_for_url(URL::URL const&) const;
String cosmetic_style_sheet_for_url(URL::URL const&, ReadonlySpan<String> classes, ReadonlySpan<String> ids) const;
bool has_generic_cosmetic_selectors_for_url(URL::URL const&, ReadonlySpan<String> classes, ReadonlySpan<String> ids) const;
static ResourceType resource_type_from_fetch_metadata(Optional<Fetch::Infrastructure::Request::Destination> const&, Optional<Fetch::Infrastructure::Request::InitiatorType> const&, Fetch::Infrastructure::Request::Mode);
static URL::URL source_url_for_matching(URL::URL const&);

View file

@ -0,0 +1,7 @@
block
block
rgb(1, 2, 3)
rgb(4, 5, 6)
PASS: irrelevant class/id changes skipped content blocker refresh
none
none

View file

@ -2,4 +2,4 @@ rgb(0, 0, 0)
inline
rgb(1, 2, 3)
none
hasInvalidationRuleCacheBuilds: 0
hasInvalidationRuleCacheBuilds: 1

View file

@ -0,0 +1,48 @@
<!DOCTYPE html>
<script src="./include.js"></script>
<style>
.irrelevant {
color: rgb(1, 2, 3);
}
#unrelated {
color: rgb(4, 5, 6);
}
</style>
<body></body>
<script>
test(() => {
internals.setContentBlockers("##.ad\n###sponsor");
const classTarget = document.createElement("div");
document.body.appendChild(classTarget);
const idTarget = document.createElement("div");
document.body.appendChild(idTarget);
println(getComputedStyle(classTarget).display);
println(getComputedStyle(idTarget).display);
internals.resetStyleInvalidationCounters();
classTarget.className = "irrelevant";
idTarget.id = "unrelated";
println(getComputedStyle(classTarget).color);
println(getComputedStyle(idTarget).color);
const counters = internals.getStyleInvalidationCounters();
if (counters.fullStyleInvalidations == 0)
println("PASS: irrelevant class/id changes skipped content blocker refresh");
else
println(`FAIL: irrelevant class/id changes caused ${counters.fullStyleInvalidations} full invalidations`);
classTarget.className = "ad";
idTarget.id = "sponsor";
println(getComputedStyle(classTarget).display);
println(getComputedStyle(idTarget).display);
internals.setContentBlockers("");
});
</script>