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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Released on Monday, November 2 2026.

- Improved the parser with a nesting limit for values and conditions (`MaxValueNestingDepth`) (#260) @michaelthyregod
- Improved indexing rules with deeply nested `:is()`, `:where()`, or `:matches()` (#261) @michaelthyregod
- Improved reading inherited properties on deep trees (e.g. `visibility` in `IsVisible` and `GetInnerText`) (#263) @michaelthyregod

# 1.3.1

Expand Down
73 changes: 73 additions & 0 deletions src/AngleSharp.Css.Tests/Styling/InheritanceChainTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace AngleSharp.Css.Tests.Styling
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Linq;

Expand Down Expand Up @@ -143,5 +144,77 @@ public void MutatingAChildStyleDoesNotChangeItsParent()
Assert.AreEqual("rgba(0, 0, 255, 1)", child.ComputedStyle.GetPropertyValue("color"));
Assert.AreEqual("rgba(255, 0, 0, 1)", parent.ComputedStyle.GetPropertyValue("color"));
}

// Lookups through the parents are remembered on the way, so the levels are read deepest
// first and then again: a value remembered below a declaration must not leak above it.
[Test]
public void DeepChainInheritsFromTheNearestDeclaringAncestor()
{
var levels = BuildChain(300, level => level switch
{
100 => "color: red",
200 => "color: blue",
_ => null,
});

for (var pass = 0; pass < 2; pass++)
{
for (var level = levels.Count - 1; level >= 0; level--)
{
// above the first declaration nothing is inherited (a computed style leaves it empty)
var expected = level >= 200 ? "rgba(0, 0, 255, 1)" : level >= 100 ? "rgba(255, 0, 0, 1)" : String.Empty;
Assert.AreEqual(expected, levels[level].ComputedStyle.GetPropertyValue("color"), $"level {level}, pass {pass}");
}
}
}

[Test]
public void DeepChainWithoutADeclarationKeepsTheInitialValue()
{
var levels = BuildChain(300, level => level == 150 ? "visibility: hidden" : null);

for (var pass = 0; pass < 2; pass++)
{
for (var level = levels.Count - 1; level >= 0; level--)
{
Assert.AreEqual(level < 150, levels[level].IsVisible(), $"level {level}, pass {pass}");
}
}

Assert.AreEqual(String.Empty, levels[0].ComputedStyle.GetPropertyValue("--undeclared"));
}

// a chain of divs built with the DOM API, with the given inline style per level
private static List<ElementRenderNode> BuildChain(int depth, Func<int, string> style)
{
var document = Parse("<style>div { margin-left: 1px }</style>");
IElement current = document.Body;

for (var level = 0; level < depth; level++)
{
var child = document.CreateElement("div");
child.Id = "level" + level;

if (style(level) is string css)
{
child.SetAttribute("style", css);
}

current.AppendChild(child);
current = child;
}

current.TextContent = "x";
var levels = new List<ElementRenderNode>();
var node = Find(document.DefaultView.Render(), "level0");

while (node is not null)
{
levels.Add(node);
node = node.Children.OfType<ElementRenderNode>().FirstOrDefault();
}

return levels;
}
}
}
124 changes: 123 additions & 1 deletion src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace AngleSharp.Css.Dom
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;

/// <summary>
/// Represents a single CSS declaration block.
Expand Down Expand Up @@ -50,6 +51,17 @@ public CssStyleDeclaration(IBrowsingContext context)
_context = context;
}

/// <summary>
/// Creates the declarations with room for the given number of entries, e.g. for
/// a cascade that knows how many declarations its matching rules bring.
/// </summary>
internal CssStyleDeclaration(IBrowsingContext context, Int32 capacity)
{
_declarations = new List<ICssProperty>(capacity);
_declarationIndex = new Dictionary<String, Int32>(capacity, StringComparer.Ordinal);
_context = context;
}

public CssStyleDeclaration(ICssRule parent)
: this(parent.Owner?.Context)
{
Expand Down Expand Up @@ -582,6 +594,8 @@ private sealed class Snapshot
private readonly Snapshot _parent;
private readonly HashSet<String> _forced;
private List<ICssProperty> _merged;
// the names looked up through this snapshot, with the nearest declaration found
private NearestEntry[] _nearest;

public Snapshot(List<ICssProperty> declarations, Dictionary<String, Int32> index, Snapshot parent, HashSet<String> forced)
{
Expand All @@ -602,8 +616,15 @@ public Snapshot(List<ICssProperty> declarations, Dictionary<String, Int32> index
/// </summary>
public static ICssProperty Inherit(Snapshot parent, HashSet<String> forced, String name)
{
if (forced is null || !forced.Contains(name))
{
// the nearest declaration decides, and a non-inheritable one is not taken
var nearest = parent.Nearest(name);
return nearest is not null && nearest.CanBeInherited ? nearest : null;
}

// a non-inheritable declaration is only taken if forced on every level
var allForced = forced is not null && forced.Contains(name);
var allForced = true;

for (var current = parent; current is not null; current = current._parent)
{
Expand Down Expand Up @@ -667,6 +688,107 @@ public static void Merge(List<ICssProperty> declarations, Dictionary<String, Int
}
}

/// <summary>
/// Finds the nearest declaration with the given name, this snapshot's own or an
/// ancestor's. The chain is as deep as the DOM, and a name declared nowhere (e.g.
/// visibility) would be looked up through all of it for every element, so the
/// answer is kept on the snapshots passed: they never change.
/// </summary>
private ICssProperty Nearest(String name)
{
var result = default(ICssProperty);
var stop = default(Snapshot);

for (var current = this; current is not null; current = current._parent)
{
result = current.FindOwn(name);

if (result is not null || current.TryGetNearest(name, out result))
{
stop = current;
break;
}
}

for (var current = this; current != stop; current = current._parent)
{
current.AddNearest(name, result);
}

return result;
}

private Boolean TryGetNearest(String name, out ICssProperty declaration)
{
var entries = Volatile.Read(ref _nearest);

if (entries is not null)
{
for (var i = 0; i < entries.Length; i++)
{
if (String.Equals(entries[i].Name, name, StringComparison.Ordinal))
{
declaration = entries[i].Declaration;
return true;
}
}
}

declaration = null;
return false;
}

private void AddNearest(String name, ICssProperty declaration)
{
// only a few names are looked up, so the entries are copied on write; a
// concurrent reader of the same computed style sees either list
var entries = Volatile.Read(ref _nearest);

while (true)
{
var count = entries?.Length ?? 0;

for (var i = 0; i < count; i++)
{
if (String.Equals(entries[i].Name, name, StringComparison.Ordinal))
{
return;
}
}

var updated = new NearestEntry[count + 1];

if (count > 0)
{
Array.Copy(entries, updated, count);
}

updated[count] = new NearestEntry(name, declaration);
var previous = Interlocked.CompareExchange(ref _nearest, updated, entries);

if (previous == entries)
{
return;
}

entries = previous;
}
}

private readonly struct NearestEntry
{
public NearestEntry(String name, ICssProperty declaration)
{
Name = name;
Declaration = declaration;
}

public String Name { get; }

// null if no snapshot up the chain declares the name
public ICssProperty Declaration { get; }
}

private ICssProperty FindOwn(String name)
{
if (_index.TryGetValue(name, out var i) && i < _declarations.Count)
Expand Down
20 changes: 20 additions & 0 deletions src/AngleSharp.Css/Dom/Internal/StyleCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace AngleSharp.Css.Dom
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;

sealed class StyleCollection : IStyleCollection
{
Expand All @@ -16,6 +17,7 @@ sealed class StyleCollection : IStyleCollection
private List<ICssStyleRule>? _rules;
private List<Int32>? _layerRanks;
private StyleRuleIndex? _index;
private List<StyleCollectionExtensions.RuleMatch>? _matches;
private Int32 _defaultRuleCount;

#endregion
Expand Down Expand Up @@ -69,6 +71,24 @@ public StyleCollection(IEnumerable<ICssStyleSheet> defaultSheets, IEnumerable<IC
/// </summary>
internal StyleRuleIndex GetIndex() => _index ??= new StyleRuleIndex(GetRules());

/// <summary>
/// Takes the list for the rules matching an element, which is reused from
/// element to element instead of growing a new one each time. The list is
/// taken atomically, so a concurrent cascade on the same collection gets
/// a new one.
/// </summary>
internal List<StyleCollectionExtensions.RuleMatch> TakeMatches() =>
Interlocked.Exchange(ref _matches, null) ?? new List<StyleCollectionExtensions.RuleMatch>();

/// <summary>
/// Gives back the list from TakeMatches once the element's style is computed.
/// </summary>
internal void ReturnMatches(List<StyleCollectionExtensions.RuleMatch> matches)
{
matches.Clear();
Volatile.Write(ref _matches, matches);
}

/// <summary>
/// Gets the rank of the cascade layer of the rule at the given index of
/// GetRules: rules of a lower rank lose against rules of a higher rank
Expand Down
35 changes: 31 additions & 4 deletions src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ public static ICssStyleDeclaration ComputeCascadedStyle(this IStyleCollection st
public static ICssStyleDeclaration ComputeExplicitStyle(this IStyleCollection styles, IElement element)
{
var ctx = element.Owner?.Context ?? throw new InvalidOperationException("The element must be associated with a browsing context.");
var computedStyle = new CssStyleDeclaration(ctx);
var matches = styles.SortBySpecificity(element);
var computedStyle = new CssStyleDeclaration(ctx, CountDeclarations(matches));
var i = 0;

for (; i < matches.Count && matches[i].IsDefault; i++)
Expand All @@ -151,6 +151,7 @@ public static ICssStyleDeclaration ComputeExplicitStyle(this IStyleCollection st
}

ApplyImportantLayerOrder(computedStyle, matches);
ReturnMatches(styles, matches);

if (element is IHtmlElement || element is ISvgElement)
{
Expand Down Expand Up @@ -221,6 +222,7 @@ internal static ICssStyleDeclaration ComputeHostStyle(this IStyleCollection styl
}

ApplyImportantLayerOrder(computedStyle, matches);
ReturnMatches(styles, matches);
computedStyle.SetDeclarations(host.GetStyle());
computedStyle.SetDeclarations(shadowStyle.Where(m => m.IsImportant).ToList());
return computedStyle;
Expand Down Expand Up @@ -312,8 +314,9 @@ private static List<RuleMatch> SortBySpecificity(this IStyleCollection styles, I
var defaultRuleCount = collection.GetDefaultRuleCount();
var candidates = collection.GetIndex().GetCandidates(element);
// not sized for the candidates: the unkeyed rules (e.g., [data-theme] or :root)
// are candidates for every element, but only a few of them match
matches = new List<RuleMatch>();
// are candidates for every element, but only a few of them match; the list
// is reused from element to element instead
matches = collection.TakeMatches();

// the candidates keep the order of the rules, as required by the cascade
foreach (var i in candidates)
Expand All @@ -337,6 +340,30 @@ private static List<RuleMatch> SortBySpecificity(this IStyleCollection styles, I
return matches;
}

/// <summary>
/// Counts the declarations of the matching rules: the cascade keeps at most
/// these, so the explicit style does not grow while they are applied.
/// </summary>
private static Int32 CountDeclarations(List<RuleMatch> matches)
{
var count = 0;

foreach (var match in matches)
{
count += match.Rule.Style.Length;
}

return count;
}

private static void ReturnMatches(IStyleCollection styles, List<RuleMatch> matches)
{
if (styles is StyleCollection collection)
{
collection.ReturnMatches(matches);
}
}

private static void MapPriority(ICssStyleRule rule, IElement element, IElement? scope, Boolean isDefault, Int32 layerRank, List<RuleMatch> matches)
{
if (rule.TryMatch(element, scope, out var specificity))
Expand All @@ -355,7 +382,7 @@ private static void MapPriority(ICssStyleRule rule, IElement element, IElement?
}
}

private readonly struct RuleMatch
internal readonly struct RuleMatch
{
public RuleMatch(ICssStyleRule rule, Boolean isDefault, Int32 layerRank, Priority priority, Int32 index)
{
Expand Down
Loading
Loading