Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
# 1.4.1

Released on Sunday, November 8 2026.

- Improved finding the rules of an element: `:root` rules and rules whose subject requires no id or class (e.g., `.list > *`, `.menu a`) are only tried on elements of that context, which made building GitHub's render tree about twice as fast
- Improved the cascade of many declarations replacing each other (e.g., the custom properties of a design system on `:root`)
- Improved reading values of computed styles, e.g., of properties that are not declared
- Improved the memory use of styles: declarations of a few entries have no index of their names, the explicit style of an element gives back the room it did not use, and resolving custom properties allocates nothing for elements without any
- Improved building the render tree of large documents by matching the selectors of their elements on all cores (turned off by the AppContext switch `AngleSharp.Css.DisableParallelism`)
- Fixed an empty custom property (e.g. `--tw-blur: ;`) being dropped, which made every value using it invalid, e.g. the `filter` of Tailwind's utilities
- Added `GetStyleCollection` to shadow roots, for the rules that style the elements of a shadow tree
- Added `RenderPartial` to the window for rendering an element into a render tree whose children are only built when they are read, e.g., to style an element again after a change of its state, or the document, telling about the children of each node once they are built, e.g., to style just what is laid out
- Added `RenderDocument` to the window for rendering the document with the given styles instead of gathering and indexing the rules of its style sheets anew
- Added `ComputeDeclarationsWithParent` to compute the style of an element (or of a pseudo-element) from the computed style of its parent, e.g., of a render tree, instead of computing the styles of all its ancestors

# 1.4.0

Released on Friday, November 6 2026.
Expand Down
2 changes: 1 addition & 1 deletion src/AngleSharp.Css.Docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anglesharp/css",
"version": "1.4.0",
"version": "1.4.1",
"preview": true,
"description": "The doclet for the AngleSharp.Css documentation.",
"keywords": [
Expand Down
12 changes: 12 additions & 0 deletions src/AngleSharp.Css.Tests/Styling/CustomPropertyCompatibility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ public void OnlyComputedStylesResolveCustomProperties(String value, String expec
Assert.AreEqual("var(--a)", declarations.GetPropertyValue("--b"));
}

[Test]
public void EmptyCustomPropertyIsSubstitutedAsWhiteSpace()
{
// what Tailwind declares for the parts of a filter not in use
using var document = ParseDocument("<style>* { --blur: ; --invert: ; } div { --invert: invert(100%); filter: var(--blur) var(--invert); }</style><div></div>");
var element = document.QuerySelector("div");
var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice());

Assert.AreEqual(2, ((ICssStyleRule)document.GetStyleSheets().OfType<ICssStyleSheet>().Single().Rules[0]).Style.Length);
Assert.AreEqual("invert(100%)", styles.ComputeDeclarations(element).GetPropertyValue("filter"));
}

[Test]
public void PublicFallbackParserPreservesNestedVariableObjects()
{
Expand Down
78 changes: 78 additions & 0 deletions src/AngleSharp.Css.Tests/Styling/ParallelRenderTreeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#nullable disable
namespace AngleSharp.Css.Tests.Styling
{
using AngleSharp.Css.RenderTree;
using AngleSharp.Css.Tests.Mocks;
using AngleSharp.Dom;
using NUnit.Framework;
using System;
using System.Linq;
using System.Text;

/// <summary>
/// The explicit styles of a large document are computed on several threads before its render
/// tree is built, which must give the same tree as computing them one after the other.
/// </summary>
[TestFixture]
public class ParallelRenderTreeTests
{
[Test]
public void ComputingStylesInParallelGivesTheSameRenderTree()
{
var html = new StringBuilder("<html><head><style>" +
":root { --gap: 4px; --fg: rgb(1, 2, 3) } " +
".list > * { margin: var(--gap) } .list > :nth-child(2n) { color: var(--fg) } " +
".card a { text-decoration: none } .card [data-x] { padding: 2px } " +
"section:nth-child(3n) .card { border: 1px solid red } #s7 * { font-weight: 700 } " +
".item::before { content: '•' } li:first-child { font-style: italic }" +
"</style></head><body>");

for (var i = 0; i < 40; i++)
{
html.Append($"<section id=s{i}><div class='card list' data-i={i}><a href=#>Link {i}</a><span data-x>x</span>");
html.Append($"<ul class=list><li class=item>a</li><li style='color: blue'>b</li><li>c</li></ul>");
html.Append(i % 10 == 0 ? "<div><template shadowrootmode=open><style>p { color: green }</style><p>s</p><slot></slot></template><b>light</b></div>" : "<p>text</p>");
html.Append("</div></section>");
}

html.Append("</body></html>");
var document = CssConstructionFunctions.ParseDocument(html.ToString());
Assert.Greater(document.All.Length, 300);

var parallel = Describe(document);

AppContext.SetSwitch(RenderTreeBuilder.DisableParallelismSwitch, true);

try
{
Assert.AreEqual(Describe(document), parallel);
}
finally
{
AppContext.SetSwitch(RenderTreeBuilder.DisableParallelismSwitch, false);
}
}

private static String Describe(IDocument document)
{
var root = (ElementRenderNode)document.DefaultView.Render(new PlainRenderDevice());
var result = new StringBuilder();
Describe(root, result);
return result.ToString();
}

private static void Describe(IRenderNode node, StringBuilder result)
{
if (node is ElementRenderNode element)
{
result.Append(element.Ref.LocalName).Append(" { ").Append(element.SpecifiedStyle.CssText).Append(" } { ")
.Append(element.ComputedStyle.CssText).AppendLine(" }");
}

foreach (var child in node.Children)
{
Describe(child, result);
}
}
}
}
39 changes: 39 additions & 0 deletions src/AngleSharp.Css.Tests/Styling/PartialRenderTreeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,45 @@ public void ConcurrentFirstReadsOfTheChildrenGetTheSameOnes()
CollectionAssert.AreEqual((System.Collections.IEnumerable)results[1], partial.Children.Where(m => m.Ref is not IPseudoElement));
}

[Test]
public void APartialTreeOfTheDocumentWithItsStylesIsTheRenderedTree()
{
var document = Parse();
var expected = (ElementRenderNode)document.DefaultView.Render();
var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 });
var partial = document.DefaultView.RenderPartial(styles);

AssertSame(expected, partial);
}

[Test]
public void APartialTreeOfTheDocumentTellsAboutTheChildrenItBuilds()
{
var document = Parse();
var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 });
var built = new System.Collections.Generic.List<(ElementRenderNode, System.Collections.Generic.IList<IRenderNode>)>();
var partial = document.DefaultView.RenderPartial(styles, (node, children) => built.Add((node, children.ToList())));
var body = partial.Children.OfType<ElementRenderNode>().Single(m => m.Ref.LocalName == "body");
var bodyChildren = body.Children.ToList();
_ = partial.Children.ToList();

// the ones read, once; not the ones of the elements in the body
CollectionAssert.AreEqual(new[] { partial, body }, built.Select(m => m.Item1));
CollectionAssert.AreEqual(partial.Children, built[0].Item2);
CollectionAssert.AreEqual(bodyChildren, built[1].Item2);
}

[Test]
public void TheDocumentRenderedWithItsStylesIsTheRenderedTree()
{
var document = Parse();
var expected = (ElementRenderNode)document.DefaultView.Render();
var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 });
var actual = document.DefaultView.RenderDocument(styles);

AssertSame(expected, actual);
}

private static bool IsGenerated(IRenderNode node) => node.Ref is IPseudoElement || node.Ref.Parent is null;

private static void AssertSame(IRenderNode expected, IRenderNode actual)
Expand Down
32 changes: 32 additions & 0 deletions src/AngleSharp.Css.Tests/Styling/ShadowStyleCollectionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#nullable disable
namespace AngleSharp.Css.Tests.Styling
{
using AngleSharp.Css.Dom;
using AngleSharp.Dom;
using NUnit.Framework;

/// <summary>
/// The elements of a shadow tree are styled by the style sheets of their shadow root, which
/// the style collection of the document does not have.
/// </summary>
[TestFixture]
public class ShadowStyleCollectionTests
{
[Test]
public void TheStyleCollectionOfAShadowRootStylesItsElementsAndPseudoElements()
{
var document = CssConstructionFunctions.ParseDocument(
"<html><head><style>p { color: red } input::placeholder { color: red }</style></head><body>" +
"<div id=host><template shadowrootmode=open><style>p { color: rgb(0, 128, 0) } .input::placeholder { color: rgb(1, 2, 3) }</style>" +
"<p>shadow</p><input class=input placeholder=x></template></div></body></html>");
var shadowRoot = document.GetElementById("host").ShadowRoot;
var styles = shadowRoot.GetStyleCollection(new DefaultRenderDevice());

var paragraph = shadowRoot.QuerySelector("p");
var input = shadowRoot.QuerySelector("input");

Assert.AreEqual("rgba(0, 128, 0, 1)", styles.ComputeDeclarations(paragraph).GetPropertyValue("color"));
Assert.AreEqual("rgba(1, 2, 3, 1)", styles.ComputeDeclarations(input, "::placeholder").GetPropertyValue("color"));
}
}
}
100 changes: 100 additions & 0 deletions src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,103 @@ public void OnlyRulesThatMayMatchAreCandidates(string css, int count)
Assert.AreEqual(count, candidates.Count);
}

[TestCase(".a > * { color: green }", "<div class=a><p id=t>A</p></div>")]
[TestCase(".a > :first-child { color: green }", "<div class=a><p id=t>A</p><p>B</p></div>")]
[TestCase(".a * { color: green }", "<div class=a><section><p id=t>A</p></section></div>")]
[TestCase(".a > * > * { color: green }", "<div class=a><section><p id=t>A</p></section></div>")]
[TestCase(".a > * + * { color: green }", "<div class=a><p>A</p><p id=t>B</p></div>")]
[TestCase(".a > * ~ * { color: green }", "<div class=a><p>A</p><i></i><p id=t>B</p></div>")]
[TestCase(".a + * > * { color: green }", "<div class=a></div><div><p id=t>A</p></div>")]
[TestCase(".x .a + * > * { color: green }", "<div class=x><div class=a></div><div><p id=t>A</p></div></div>")]
[TestCase(".a span { color: green }", "<div class=a><b><span id=t>A</span></b></div>")]
[TestCase(".a [data-x] { color: green }", "<div class=a><p id=t data-x>A</p></div>")]
[TestCase(".a, .b > * { color: green }", "<div class=b><p id=t>A</p></div>")]
[TestCase("#x > * { color: green }", "<div id=x><p id=t>A</p></div>")]
[TestCase(":root > * > * { color: green }", "<p id=t>A</p>")]
[TestCase("div > * { color: green }", "<div><p id=t>A</p></div>")]
[TestCase(".a:hover > * { color: green }", "<div class=a><p id=t>A</p></div>", "a")]
[TestCase(".a > *::before, .a > * { color: green }", "<div class=a><p id=t>A</p></div>")]
public async Task RulesAreFoundViaTheParentOrAncestorOfTheirSubject(string css, string body, string forcedHover = null)
{
var context = BrowsingContext.New(Configuration.Default.WithCss());
var document = await context.OpenAsync(req => req.Content($"<!doctype html><style>{css}</style>{body}"));

if (forcedHover is not null)
{
document.QuerySelector("." + forcedHover).SetPseudoClass("hover", true);
}

var color = document.DefaultView.GetComputedStyle(document.GetElementById("t")).GetColor();

Assert.AreEqual(Green, color);
}

[TestCase("* { color: blue } .a > * { color: red }", "<div class=a><div><p id=t>A</p></div></div>")]
[TestCase("* { color: blue } .a * { color: red }", "<div class=b><p id=t>A</p></div><div class=a></div>")]
[TestCase("* { color: blue } .a + * { color: red }", "<div class=a><p id=t>A</p></div>")]
[TestCase("* { color: blue } .a span { color: red }", "<div class=a><b id=t>A</b></div>")]
public async Task RulesForOtherContextsDoNotApply(string css, string body)
{
var color = await ColorOfAsync(css, body);

Assert.AreNotEqual("rgba(255, 0, 0, 1)", color);
}

[TestCase(".a > * { color: red }", 1)]
[TestCase(".x > * { color: red }", 0)]
[TestCase(".a * { color: red }", 1)]
[TestCase(".b * { color: red }", 1)]
[TestCase(".x * { color: red }", 0)]
[TestCase(".b > * { color: red }", 0)]
[TestCase(".x + * { color: red }", 1)]
[TestCase(".x > * + * { color: red }", 0)]
[TestCase(".x span { color: red }", 0)]
[TestCase(".a p { color: red }", 1)]
[TestCase(":root > * > * > * { color: red }", 1)]
[TestCase(":root { color: red } html > * { color: blue }", 0)]
[TestCase(".x || p { color: red }", 1)]
public void OnlyRulesWhoseContextMayMatchAreCandidates(string css, int count)
{
var document = CssConstructionFunctions.ParseDocument($"<html><head><style>{css}</style></head><body><div class=b><div class=a><p id=t>A</p></div></div></body></html>");
var sheets = document.StyleSheets.OfType<ICssStyleSheet>().ToList();
var collection = new StyleCollection(Enumerable.Empty<ICssStyleSheet>(), sheets, new PlainRenderDevice());
var candidates = collection.GetIndex().GetCandidates(document.GetElementById("t"));

Assert.AreEqual(count, candidates.Count);
}

[Test]
public void ForcedRootIsFoundViaTheRootGroup()
{
var document = CssConstructionFunctions.ParseDocument("<html><head><style>:root { color: red }</style></head><body><p id=t>A</p></body></html>");
var sheets = document.StyleSheets.OfType<ICssStyleSheet>().ToList();
var collection = new StyleCollection(Enumerable.Empty<ICssStyleSheet>(), sheets, new PlainRenderDevice());
var element = document.GetElementById("t");

Assert.AreEqual(0, collection.GetIndex().GetCandidates(element).Count);
Assert.AreEqual(1, collection.GetIndex().GetCandidates(document.DocumentElement).Count);

element.SetPseudoClass("root", true);

Assert.AreEqual(1, collection.GetIndex().GetCandidates(element).Count);
}

[Test]
public async Task ContextOfTheShadowHostIsMatched()
{
var context = BrowsingContext.New(Configuration.Default.WithCss());
var document = await context.OpenAsync(req => req.Content(
"<!doctype html><div id=host class=h><template shadowrootmode=open>" +
"<style>:host(.h) > * { color: green } :host .a > * { background-color: green }</style>" +
"<p id=t>A</p><div class=a><p id=u>B</p></div></template></div>"));
var shadow = document.GetElementById("host").ShadowRoot;
var tree = document.DefaultView.Render(new PlainRenderDevice());
var nodes = Flatten((ElementRenderNode)tree).ToArray();

Assert.AreEqual(Green, nodes.First(m => m.Ref == shadow.GetElementById("t")).ComputedStyle.GetPropertyValue("color"));
Assert.AreEqual(Green, nodes.First(m => m.Ref == shadow.GetElementById("u")).ComputedStyle.GetPropertyValue("background-color"));
}

[Test]
public void PseudoElementsAreOnlyGeneratedWithContent()
{
Expand Down Expand Up @@ -145,6 +242,9 @@ public void PseudoElementsAreOnlyGeneratedWithContent()
[TestCase("p::before { content: 'x' }", "<div id=t>A</div>", false)]
[TestCase(".a:before { content: 'x' }", "<p id=t class=a>A</p>", true)]
[TestCase(".a .b::after { content: 'x' }", "<div class=a><p id=t class=b>A</p></div>", true)]
[TestCase(".a > *::after { content: 'x' }", "<div class=a><p id=t>A</p></div>", true)]
[TestCase(".a > *::after { content: 'x' }", "<div class=b><p id=t>A</p></div>", false)]
[TestCase(".a span::after { content: 'x' }", "<div class=a><b><span id=t>A</span></b></div>", true)]
[TestCase("::before { content: 'x' }", "<p id=t>A</p>", true)]
[TestCase(".x::before, #t::after { content: 'x' }", "<p id=t>A</p>", true)]
public void PseudoElementRulesAreFoundViaTheirElement(string css, string body, bool generated)
Expand Down
Loading
Loading