ladybird/Tests/LibWeb/Text/input/css/shadow-root-host-light-dom-trailing-universal-invalidation.html
Jelle Raaijmakers 2f39b2dd63 LibWeb: Split shadow stylesheet host-side invalidation reach
Broad shadow-root stylesheet changes already restyle the whole shadow
tree, but host-side fallout does not always need a document-wide
invalidation. Split the host-side reach classification so selectors
contained to the host subtree, such as `:host *` and `:host > *`,
invalidate the host, while sibling-escaping selectors such as
`:host + :has(*)` still invalidate the host's root.

Recognize sibling escapes through positive selector-list pseudos such as
`:is()` and `:where()` as well, including selectors like
`:is(:host) + :has(*)` and `:is(:host + .item)`.

This avoids turning host-contained shadow stylesheet changes into full
document style invalidations. On https://pomax.github.io/bezierinfo/,
this reduces the time to produce a layout tree from about 8.7s to 3.6s
on my machine.
2026-05-05 18:36:34 +02:00

144 lines
7.4 KiB
HTML

<!DOCTYPE html>
<script src="../include.js"></script>
<div id="host-child"><span id="direct-child">direct child</span></div>
<div id="host-descendant"><div><span id="nested-descendant">nested descendant</span></div></div>
<script>
function settleAndReset(triggerElement) {
getComputedStyle(triggerElement).color;
getComputedStyle(triggerElement).color;
internals.resetStyleInvalidationCounters();
}
function verifyInvalidationsStayHostSide(label, maxInvalidations) {
const counters = internals.getStyleInvalidationCounters();
if (counters.fullStyleInvalidations == 0 && counters.styleInvalidations > 0 && counters.styleInvalidations <= maxInvalidations)
println(`PASS: ${label} (${counters.styleInvalidations} invalidations)`);
else
println(`FAIL: ${label} (${counters.styleInvalidations} invalidations, ${counters.fullStyleInvalidations} full invalidations)`);
}
function verifyInvalidationsReachRootSide(label) {
const counters = internals.getStyleInvalidationCounters();
if (counters.fullStyleInvalidations > 0)
println(`PASS: ${label} (${counters.fullStyleInvalidations} full invalidations)`);
else
println(`FAIL: ${label} (${counters.styleInvalidations} invalidations, ${counters.fullStyleInvalidations} full invalidations)`);
}
function populateShadowBystanders(shadowRoot, prefix) {
for (let i = 0; i < 25; ++i) {
const bystander = document.createElement("span");
bystander.textContent = `${prefix} shadow bystander ${i}`;
shadowRoot.appendChild(bystander);
}
}
test(() => {
const hostChild = document.getElementById("host-child");
const directChild = document.getElementById("direct-child");
const hostDescendant = document.getElementById("host-descendant");
const nestedDescendant = document.getElementById("nested-descendant");
const childShadowRoot = hostChild.attachShadow({ mode: "open" });
const descendantShadowRoot = hostDescendant.attachShadow({ mode: "open" });
populateShadowBystanders(childShadowRoot, "child");
populateShadowBystanders(descendantShadowRoot, "descendant");
settleAndReset(directChild);
const directStyle = document.createElement("style");
directStyle.textContent = ":host > * { color: rgb(0, 128, 0); }";
childShadowRoot.appendChild(directStyle);
getComputedStyle(directChild).color;
// In a shadow tree, :host > * targets the host's light-DOM children.
// The invalidation should therefore stay bounded to that host-side
// subtree instead of scaling with the unrelated shadow descendants.
verifyInvalidationsStayHostSide("host child combinator add stays on the host-side tree", 4);
settleAndReset(nestedDescendant);
const descendantStyle = document.createElement("style");
descendantStyle.textContent = ":host * { color: rgb(128, 0, 128); }";
descendantShadowRoot.appendChild(descendantStyle);
getComputedStyle(nestedDescendant).color;
verifyInvalidationsStayHostSide("host descendant combinator add stays on the host-side tree", 4);
const broadHost = document.createElement("div");
broadHost.innerHTML = "<span id='broad-target'>broad target</span>";
document.body.appendChild(broadHost);
for (let i = 0; i < 25; ++i) {
const bystander = document.createElement("span");
bystander.textContent = `document bystander ${i}`;
document.body.appendChild(bystander);
}
const broadTarget = document.getElementById("broad-target");
const broadShadowRoot = broadHost.attachShadow({ mode: "open" });
settleAndReset(broadTarget);
const broadStyle = document.createElement("style");
broadStyle.textContent = `
@keyframes shadow-root-host-side-broad-invalidation {
from { opacity: 1; }
to { opacity: 1; }
}
:host * { color: rgb(0, 0, 255); }
`;
broadShadowRoot.appendChild(broadStyle);
getComputedStyle(broadTarget).color;
verifyInvalidationsStayHostSide("broad host-side stylesheet add avoids document-wide invalidation", 4);
const siblingEscapeHost = document.createElement("div");
document.body.appendChild(siblingEscapeHost);
const siblingEscapeTarget = document.createElement("div");
siblingEscapeTarget.innerHTML = "<span>sibling escape target</span>";
document.body.appendChild(siblingEscapeTarget);
const siblingEscapeShadowRoot = siblingEscapeHost.attachShadow({ mode: "open" });
settleAndReset(siblingEscapeTarget);
const siblingEscapeStyle = document.createElement("style");
siblingEscapeStyle.textContent = `
@keyframes shadow-root-host-side-sibling-escape-invalidation {
from { opacity: 1; }
to { opacity: 1; }
}
:host + :has(*) { color: rgb(255, 0, 0); }
`;
siblingEscapeShadowRoot.appendChild(siblingEscapeStyle);
getComputedStyle(siblingEscapeTarget).color;
verifyInvalidationsReachRootSide("broad host-side sibling escape reaches root-side invalidation");
const functionalHost = document.createElement("div");
functionalHost.innerHTML = "<span id='functional-host-target'>functional host target</span>";
document.body.appendChild(functionalHost);
const functionalHostTarget = document.getElementById("functional-host-target");
const functionalHostShadowRoot = functionalHost.attachShadow({ mode: "open" });
settleAndReset(functionalHostTarget);
const functionalHostStyle = document.createElement("style");
functionalHostStyle.textContent = `
@keyframes shadow-root-functional-host-side-invalidation {
from { opacity: 1; }
to { opacity: 1; }
}
:is(:host) * { color: rgb(0, 0, 255); }
`;
functionalHostShadowRoot.appendChild(functionalHostStyle);
getComputedStyle(functionalHostTarget).color;
verifyInvalidationsStayHostSide("broad functional host-side selector avoids document-wide invalidation", 4);
const functionalSiblingEscapeHost = document.createElement("div");
document.body.appendChild(functionalSiblingEscapeHost);
const functionalSiblingEscapeTarget = document.createElement("div");
functionalSiblingEscapeTarget.innerHTML = "<span>functional sibling escape target</span>";
document.body.appendChild(functionalSiblingEscapeTarget);
const functionalSiblingEscapeShadowRoot = functionalSiblingEscapeHost.attachShadow({ mode: "open" });
settleAndReset(functionalSiblingEscapeTarget);
const functionalSiblingEscapeStyle = document.createElement("style");
functionalSiblingEscapeStyle.textContent = `
@keyframes shadow-root-functional-sibling-escape-invalidation {
from { opacity: 1; }
to { opacity: 1; }
}
:is(:host) + :has(*) { color: rgb(255, 0, 0); }
`;
functionalSiblingEscapeShadowRoot.appendChild(functionalSiblingEscapeStyle);
getComputedStyle(functionalSiblingEscapeTarget).color;
verifyInvalidationsReachRootSide("broad functional host-side sibling escape reaches root-side invalidation");
});
</script>