Skip to content
Closed
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 @@ -2,6 +2,7 @@

Released on Saturday, September 12 2026

- Added a synchronous stylesheet mutation version for CSSOM cache invalidation without DOM mutation records
- Improved pseudo-class matching to skip a per-element `ConditionalWeakTable` probe when no state has ever been forced via `SetPseudoClass`

# 1.1.2
Expand Down
37 changes: 37 additions & 0 deletions docs/general/MutationVersion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# CSSOM mutation versions

A host that caches stylesheet-derived work can read `sheet.GetMutationVersion()` before and after a query. The nullable `Int64` is an opaque equality token. It advances synchronously for native CSSOM writes, including declaration/property edits, selector and condition changes, rule-list edits, sheet media and disabled state. Reading it allocates nothing. A no-op or failed operation that already changed state may advance it; do not use its magnitude or ordering. Read and mutate on the thread that owns the CSSOM.

Parser construction does not advance the version. Normal synchronous and asynchronous parses finish at
zero; no counter reset is used, so user mutations made in parser callbacks remain visible. Parsing a
rule with an existing sheet as its owner also leaves that sheet's version unchanged. User insertion or
`CssText` replacement publishes the change after installing the parsed result. Parser construction uses
raw rule-list and declaration operations, and the parser's selector/condition initialization does not
call the notifying setters. Hosts must invalidate across parsing/resumption and resource/import loading
boundaries independently; a stylesheet version is not a signal that loading completed.

This is a separate signal from the document's DOM mutations. For example:

```csharp
var sheet = (ICssStyleSheet)document.StyleSheets[0];
var rule = (ICssStyleRule)sheet.Rules[0];
var version = sheet.GetMutationVersion();
rule.Style.SetProperty("display", "none");
// The style element's text and DOM attributes have not changed.
// A DOM MutationObserver receives no record, but the stylesheet version changes.
```

The motivating consumer is Jint.Browser's flat layout: an unchanged geometry read should reuse prior work, while a read immediately after a native host CSSOM write must recompute it. An external wrapper only sees writes made through that wrapper. CSSOM implementations are internal and sealed, and existing interfaces offer no synchronous change hook to intercept these native writes. DOM observers cannot report these changes, even if their record queue is drained synchronously. Walking or serializing the CSSOM can detect changes, but repeats work proportional to stylesheet size on every otherwise-unchanged read. The notification therefore belongs at the native mutation points; the cache and layout policy remain outside AngleSharp.Css.

Track each imported sheet separately. Also track document changes, stylesheet membership/loading, render-device/media environment and host-owned selector state. The version does not claim to version these other inputs. Unsupported stylesheet implementations return `null`; custom rules, selectors, properties or mutable value objects need an explicit invalidation policy or an uncached fallback. The new extension does not add a member to `ICssStyleSheet` or require third-party implementations to change.

The native-write reproducer was also run against the unmodified `devel` source at `650fb47`, using AngleSharp `1.8.2-beta.715` (which already has the DOM version). A cache keyed by both the DOM version and observer record count still returned the old display value:

```text
DOM version unchanged: True
DOM markup unchanged: True
Mutation records: 0
Cached display: block; actual display: none
```

`NativeCssomMutationIsInvisibleToADomObserver` keeps this case in the test suite and verifies that the new stylesheet revision allows the cache to refresh. It also checks that computing style does not change the source sheet's version.
260 changes: 260 additions & 0 deletions src/AngleSharp.Css.Tests/Rules/CssMutationVersion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
namespace AngleSharp.Css.Tests.Rules
{
using AngleSharp.Css.Dom;
using AngleSharp.Css.Dom.Events;
using AngleSharp.Css.Parser;
using AngleSharp.Css.Tests.Mocks;
using AngleSharp.Io;
using AngleSharp.Dom;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using static CssConstructionFunctions;

[TestFixture]
public class CssMutationVersionTests
{
private static IEnumerable<TestCaseData> RuleChanges()
{
yield return Change("declaration", "a { display: block }", r => ((ICssStyleRule)r).Style.SetProperty("display", "none"));
yield return Change("remove declaration", "a { display: block }", r => ((ICssStyleRule)r).Style.RemoveProperty("display"));
yield return Change("declaration text", "a { display: block }", r => ((ICssStyleRule)r).Style.CssText = "");
yield return Change("property value", "a { display: block }", r => ((ICssStyleRule)r).Style.GetProperty("display")!.Value = "none");
yield return Change("property priority", "a { display: block }", r => ((ICssStyleRule)r).Style.GetProperty("display")!.IsImportant = true);
yield return Change("selector", "a { display: block }", r => ((ICssStyleRule)r).SelectorText = "b");
yield return Change("rule text", "a { display: block }", r => r.CssText = "b { display: none }");
yield return Change("nested declaration", "@media screen { a { display: block } }", r => ((ICssStyleRule)((ICssMediaRule)r).Rules[0]).Style.SetProperty("display", "none"));
yield return Change("group insertion", "@media screen {}", r => ((ICssMediaRule)r).Insert("a {}", 0));
yield return Change("group removal", "@media screen { a {} }", r => ((ICssMediaRule)r).RemoveAt(0));
yield return Change("group replacement", "@media screen { a {} }", r => r.CssText = "@media print {}");
yield return Change("media text", "@media screen {}", r => ((ICssMediaRule)r).Media.MediaText = "print");
yield return Change("media append", "@media screen {}", r => ((ICssMediaRule)r).Media.Add("print"));
yield return Change("media removal", "@media screen {}", r => ((ICssMediaRule)r).Media.Remove("screen"));
yield return Change("supports condition", "@supports (display: block) {}", r => ((ICssSupportsRule)r).ConditionText = "(display: none)");
yield return Change("container condition", "@container (width > 1px) {}", r => ((ICssContainerRule)r).ConditionText = "(width > 2px)");
yield return Change("scope", "@scope (.a) {}", r => ((ICssScopeRule)r).ScopeText = "(.b)");
yield return Change("page selector", "@page :left { margin: 1px }", r => ((ICssPageRule)r).SelectorText = ":right");
yield return Change("keyframes name", "@keyframes a { from { opacity: 0 } }", r => ((ICssKeyframesRule)r).Name = "b");
yield return Change("keyframe selector", "@keyframes a { from { opacity: 0 } }", r => ((ICssKeyframeRule)((ICssKeyframesRule)r).Rules[0]).KeyText = "to");
yield return Change("font declaration", "@font-face { font-family: a }", r => ((ICssFontFaceRule)r).Family = "b");
yield return Change("descriptor", "@property --x { syntax: '*'; inherits: false }", r => ((ICssPropertyRule)r).SetProperty("inherits", "true"));
yield return Change("descriptor property", "@property --x { syntax: '*'; inherits: false }", r => ((ICssPropertyRule)r).GetProperty("inherits")!.Value = "true");
}

private static TestCaseData Change(String name, String css, Action<ICssRule> change) =>
new TestCaseData(css, change).SetName("MutationVersion: " + name);

[TestCaseSource(nameof(RuleChanges))]
public void RuleMutationAdvancesVersion(String css, Action<ICssRule> change)
{
var sheet = ParseStyleSheet(css);
var before = sheet.GetMutationVersion();
Assert.AreEqual(0, before, "Constructing a stylesheet must not advance its version.");

change(sheet.Rules[0]);

Assert.AreNotEqual(before, sheet.GetMutationVersion());
var after = sheet.GetMutationVersion();
Assert.IsNotNull(sheet.ToCss());
Assert.AreEqual(after, sheet.GetMutationVersion(), "Reading CSS must not invalidate it.");
}

[TestCase("@charset 'utf-8'; @import 'a.css' screen; @namespace x 'urn:x'; x|a { color: red !important }")]
[TestCase("@document url('https://example.org') { a { color: red } }")]
[TestCase("@supports (display: block) { @container (width > 1px) { a { & b { display: none } } } }")]
[TestCase("@property --x { syntax: '*'; inherits: false } @font-face { font-family: a; src: url(a.woff) }")]
[TestCase("@scope (.a) { a {} } @keyframes a { from { opacity: 0 } } @page :left { margin: 1px }")]
[TestCase("@media screen { a { color: red } } @supports INVALID {} b { color: red; invalid }")]
public async Task SyncAndAsyncConstructionLeaveVersionAtZero(String css)
{
var parser = new CssParser();
var synchronous = parser.ParseStyleSheet(css);
var asynchronous = await parser.ParseStyleSheetAsync(css).ConfigureAwait(false);

Assert.Greater(synchronous.Rules.Length, 0);
Assert.AreEqual(0, synchronous.GetMutationVersion());
Assert.AreEqual(0, asynchronous.GetMutationVersion());
}

[TestCase("@media screen { a { color: red } }")]
[TestCase("@font-face { font-family: a }")]
[TestCase("@property --x { syntax: '*'; inherits: false }")]
[TestCase("@supports INVALID {}")]
public void ParsingARuleDoesNotMutateItsOwner(String css)
{
var parser = new CssParser();
var sheet = parser.ParseStyleSheet("a {}");
sheet.IsDisabled = true;
var before = sheet.GetMutationVersion();

parser.ParseRule(sheet, css);

Assert.AreEqual(before, sheet.GetMutationVersion());
Assert.AreEqual(1, sheet.Rules.Length);
}

[Test]
public void ConstructionDoesNotResetUserMutationsInParseCallbacks()
{
var parser = new CssParser();
Int64? afterUserMutation = null;
parser.Parsing += (_, ev) =>
{
var sheet = ((CssParseEvent)ev).StyleSheet;
Assert.AreEqual(0, sheet.GetMutationVersion());
sheet.IsDisabled = true;
afterUserMutation = sheet.GetMutationVersion();
};

var result = parser.ParseStyleSheet("@media screen { a { display: block } }");

Assert.Greater(afterUserMutation, 0);
Assert.AreEqual(afterUserMutation, result.GetMutationVersion());
}

[TestCase(false)]
[TestCase(true)]
public async Task LoadingAStylesheetAndItsImportsLeavesVersionsAtZero(Boolean disabled)
{
var files = new Dictionary<String, String> { { "child.css", "a { color: red }" } };
var config = Configuration.Default.With(new TestServerRequester(files))
.WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }).WithCss();
using var context = BrowsingContext.New(config);
var document = await context.OpenAsync(req => req.Address("http://localhost/index.html")
.Content("<style></style>")).ConfigureAwait(false);
using var response = new DefaultResponse
{
Address = new Url("http://localhost/parent.css"),
Content = new MemoryStream(Encoding.UTF8.GetBytes("@import 'child.css';"))
};
var options = new StyleOptions(document) { Element = document.QuerySelector("style"), IsDisabled = disabled };

var sheet = (ICssStyleSheet)await new CssStylingService()
.ParseStylesheetAsync(response, options, CancellationToken.None).ConfigureAwait(false);
var imported = ((ICssImportRule)sheet.Rules[0]).Sheet;

Assert.AreEqual(disabled, sheet.IsDisabled);
Assert.IsNotNull(imported);
Assert.AreEqual(1, imported!.Rules.Length);
Assert.AreEqual(0, sheet.GetMutationVersion());
Assert.AreEqual(0, imported.GetMutationVersion());
}

[Test]
public void SheetMutationsAdvanceOnlyTheirOwnVersion()
{
var first = ParseStyleSheet("a {}");
var second = ParseStyleSheet("b {}");
var firstVersion = first.GetMutationVersion();
var before = second.GetMutationVersion();
second.Insert("c {}", 1);
Assert.AreNotEqual(before, second.GetMutationVersion());
before = second.GetMutationVersion();
second.RemoveAt(1);
Assert.AreNotEqual(before, second.GetMutationVersion());
before = second.GetMutationVersion();
second.IsDisabled = true;
Assert.AreNotEqual(before, second.GetMutationVersion());
before = second.GetMutationVersion();
second.Media.MediaText = "print";
Assert.AreNotEqual(before, second.GetMutationVersion());
Assert.AreEqual(firstVersion, first.GetMutationVersion());
}

[Test]
public void RevisionAdvancesBeforeDeclarationCallbacks()
{
var sheet = ParseStyleSheet("a { display: block }");
var style = (CssStyleDeclaration)((ICssStyleRule)sheet.Rules[0]).Style;
var before = sheet.GetMutationVersion();
var observed = before;
style.Changed += _ => observed = sheet.GetMutationVersion();

style.SetProperty("display", "none");

Assert.AreNotEqual(before, observed);
}

[Test]
public void ReentrantDeclarationMutationAdvancesVersionWithoutRepeatingCallbacks()
{
var sheet = ParseStyleSheet("a { display: block }");
var style = (CssStyleDeclaration)((ICssStyleRule)sheet.Rules[0]).Style;
var callbacks = 0;
style.Changed += _ =>
{
callbacks++;
var before = sheet.GetMutationVersion();
style.SetProperty("color", "red");
Assert.AreNotEqual(before, sheet.GetMutationVersion());
};

style.SetProperty("display", "none");

Assert.AreEqual(1, callbacks);
}

[Test]
public void InvalidMediaThatClearsStateStillAdvancesVersion()
{
var sheet = ParseStyleSheet("a {}");
sheet.Media.MediaText = "screen";
var before = sheet.GetMutationVersion();

Assert.Throws<DomException>(() => sheet.Media.MediaText = "@");

Assert.AreNotEqual(before, sheet.GetMutationVersion());
}

[Test]
public void DetachedGroupUsesItsNewSheet()
{
var first = ParseStyleSheet("@media screen { a { display: block } }");
var group = first.Rules[0];
var second = ParseStyleSheet("");
first.RemoveAt(0);
((CssStyleSheet)second).Add(group);
var firstVersion = first.GetMutationVersion();
var secondVersion = second.GetMutationVersion();

((ICssStyleRule)((ICssMediaRule)group).Rules[0]).Style.SetProperty("display", "none");

Assert.AreEqual(firstVersion, first.GetMutationVersion());
Assert.AreNotEqual(secondVersion, second.GetMutationVersion());
}

[Test]
public async Task NativeCssomMutationIsInvisibleToADomObserver()
{
using var context = BrowsingContext.New(Configuration.Default.WithCss());
var document = await context.OpenAsync(req => req.Content("<style>div { display: block }</style><div></div>")).ConfigureAwait(false);
var sheet = (ICssStyleSheet)document.StyleSheets[0]!;
Assert.AreEqual(0, sheet.GetMutationVersion());
var rule = (ICssStyleRule)sheet.Rules[0];
var markup = document.DocumentElement.OuterHtml;
var records = 0;
var observer = new MutationObserver((changes, _) => records += changes.Length);
observer.Connect(document, childList: true, subtree: true, attributes: true, characterData: true);
var version = sheet.GetMutationVersion();
var cachedDisplay = rule.Style.GetPropertyValue("display");

// Native CSSOM writes do not rewrite the style element's text or pass through a host wrapper.
rule.Style.SetProperty("display", "none");
if (version != sheet.GetMutationVersion())
{
cachedDisplay = rule.Style.GetPropertyValue("display");
}

Assert.AreEqual(markup, document.DocumentElement.OuterHtml);
Assert.AreEqual(0, records);
Assert.AreEqual("none", cachedDisplay);
var after = sheet.GetMutationVersion();
Assert.AreEqual("none", document.DefaultView!.GetComputedStyle(document.QuerySelector("div")!).GetPropertyValue("display"));
Assert.AreEqual(after, sheet.GetMutationVersion(), "Computing style must not invalidate the source sheet.");
}
}
}
3 changes: 1 addition & 2 deletions src/AngleSharp.Css/CssStylingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,8 @@ public async Task<IStyleSheet> ParseStylesheetAsync(IResponse response, StyleOpt
var parser = context.GetService<ICssParser>();
var url = response.Address?.Href;
var source = new TextSource(response.Content);
var sheet = new CssStyleSheet(context, source)
var sheet = new CssStyleSheet(context, source, options.IsDisabled)
{
IsDisabled = options.IsDisabled,
Href = url
};
sheet.SetOwner(options.Element);
Expand Down
14 changes: 14 additions & 0 deletions src/AngleSharp.Css/Dom/Internal/CssProperty.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ public String Value
}
}

String ICssProperty.Value
{
get => Value;
set { Value = value; MutationOwner?.MarkChanged(); }
}

Boolean ICssProperty.IsImportant
{
get => IsImportant;
set { IsImportant = value; MutationOwner?.MarkChanged(); }
}

public Boolean HasValue => _value != null;

public PropertyFlags Flags => _flags;
Expand Down Expand Up @@ -98,6 +110,8 @@ public Boolean IsImportant

#region Internal Properties

internal ICssMutationTracker MutationOwner { get; set; }

internal Boolean CanBeHashless => (_flags & PropertyFlags.Hashless) == PropertyFlags.Hashless;

internal Boolean CanBeUnitless => (_flags & PropertyFlags.Unitless) == PropertyFlags.Unitless;
Expand Down
16 changes: 15 additions & 1 deletion src/AngleSharp.Css/Dom/Internal/CssRule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace AngleSharp.Css.Dom
/// <summary>
/// Represents a CSS rule.
/// </summary>
abstract class CssRule : ICssRule
abstract class CssRule : ICssRule, ICssMutationTracker
{
#region Fields

Expand Down Expand Up @@ -46,6 +46,7 @@ public String CssText
throw new DomException(DomError.InvalidModification);

ReplaceWith(rule);
MarkChanged();
}
}

Expand Down Expand Up @@ -95,6 +96,19 @@ public void SetOwner(ICssStyleSheet sheet)

#region Helpers

public void MarkChanged()
{
// Resolve through the current parent: a detached group may later join another sheet.
if (_parent is CssRule parent)
{
parent.MarkChanged();
}
else if (_owner is CssStyleSheet sheet)
{
sheet.MarkChanged();
}
}

protected abstract void ReplaceWith(ICssRule rule);

protected ISelector ParseSelector(String selectorText)
Expand Down
Loading
Loading