using AngleSharp.Dom;
using AngleSharp.Html.Parser;
namespace XFESystem.Browser.Tests;
public sealed class BrowserCssCacheTests
{
private static IDocument Page(string css, string content = "<div id=A>Code</div>") =>
new HtmlParser().ParseDocument("<style id=Css>" + css + "</style>" + content);
private static BrowserDisplayList Layout(BrowserLayoutEngine engine, IDocument page, double width = 800, double height = 600) => engine.Layout(page, new(width, height));
private static IElement A(IDocument page) => page.GetElementById("A")!;
[Fact]
public void RepeatedLayoutReusesAuthorRulesWhileCurrentDomStylesStillChange()
{
var page = Page("#A{width:100px;height:20px}"); var engine = new BrowserLayoutEngine();
Layout(engine, page); A(page).SetAttribute("style", "width:180px"); var next = Layout(engine, page);
Assert.Equal(180, next.GetBoundingClientRect(A(page)).Width); Assert.Equal(1, engine.CssParseGeneration); Assert.Equal(1, engine.CssReuseCount);
}
[Fact]
public void TextDataMutationInvalidatesCachedCascadeWithoutElementTextContentConcatenation()
{
var page = Page("#A{width:100px}"); var engine = new BrowserLayoutEngine(); Layout(engine, page);
((IText)page.GetElementById("Css")!.FirstChild!).Data = "#A{width:177px}";
Assert.Equal(177, Layout(engine, page).GetBoundingClientRect(A(page)).Width); Assert.Equal(2, engine.CssParseGeneration);
}
[Fact]
public void MultiNodeStyleTextPreservesConcatenationAndInvalidatesChangesAndOrder()
{
var page = Page(""); var css = page.GetElementById("Css")!;
css.AppendChild(page.CreateTextNode("#A{width:")); var value = page.CreateTextNode("100px"); css.AppendChild(value); css.AppendChild(page.CreateComment("ignored")); css.AppendChild(page.CreateTextNode("}"));
var engine = new BrowserLayoutEngine(); Assert.Equal(100, Layout(engine, page).GetBoundingClientRect(A(page)).Width);
Assert.Equal(100, Layout(engine, page).GetBoundingClientRect(A(page)).Width); Assert.Equal(1, engine.CssReuseCount);
value.Data = "188px"; Assert.Equal(188, Layout(engine, page).GetBoundingClientRect(A(page)).Width); Assert.Equal(2, engine.CssParseGeneration);
}
[Fact]
public void LinkedOwnerRemovalAndAuthorOrderChangeRebuildActualCascade()
{
var page = new HtmlParser().ParseDocument("<style>#A{width:100px}</style><link id=Link rel=stylesheet><style id=Last>#A{width:300px}</style><div id=A></div>");
var link = page.GetElementById("Link")!; var last = page.GetElementById("Last")!; var sheets = new[] { new BrowserStyleSheet("#A{width:200px}", link) };
var engine = new BrowserLayoutEngine(); Assert.Equal(300, engine.Layout(page, new(800, 600), sheets).GetBoundingClientRect(A(page)).Width);
page.Head!.InsertBefore(last, link); Assert.Equal(200, engine.Layout(page, new(800, 600), sheets).GetBoundingClientRect(A(page)).Width);
link.Remove(); Assert.Equal(300, engine.Layout(page, new(800, 600), sheets).GetBoundingClientRect(A(page)).Width); Assert.Equal(3, engine.CssParseGeneration);
}
[Fact]
public void WidthAndHeightMediaConditionsAndViewportUnitsUseCurrentViewport()
{
var page = Page("#A{width:100px;height:10vh}@media(min-width:600px){#A{width:200px}}@media(min-height:500px){#A{width:300px}}");
var engine = new BrowserLayoutEngine(); Assert.Equal(300, Layout(engine, page).GetBoundingClientRect(A(page)).Width);
var shortPage = Layout(engine, page, 800, 400); Assert.Equal(200, shortPage.GetBoundingClientRect(A(page)).Width); Assert.Equal(40, shortPage.GetBoundingClientRect(A(page)).Height);
Assert.Equal(100, Layout(engine, page, 400, 400).GetBoundingClientRect(A(page)).Width); Assert.Equal(3, engine.CssParseGeneration);
}
[Fact]
public void CachedSelectorsReadLiveHoverActiveAndFocusStateWithoutReparsing()
{
var page = Page("#A{width:100px}#A:hover{width:150px}#A:active{width:180px}#A:focus{height:70px}"); var engine = new BrowserLayoutEngine();
Layout(engine, page); engine.SetInteractionState(A(page), null, null); Assert.Equal(150, Layout(engine, page).GetBoundingClientRect(A(page)).Width);
engine.SetInteractionState(A(page), A(page), A(page)); var active = Layout(engine, page); Assert.Equal(180, active.GetBoundingClientRect(A(page)).Width); Assert.Equal(70, active.GetBoundingClientRect(A(page)).Height);
engine.SetInteractionState(null, null, null); Assert.Equal(100, Layout(engine, page).GetBoundingClientRect(A(page)).Width);
Assert.Equal(1, engine.CssParseGeneration); Assert.Equal(3, engine.CssReuseCount);
}
[Fact]
public void NavigationClearsPreviousDocumentOwnersAndInteractionState()
{
var page = Page("#A:hover{width:100px}"); var engine = new BrowserLayoutEngine(); engine.SetInteractionState(A(page), null, null); Layout(engine, page);
var second = Page("#A{width:180px}"); Assert.Equal(180, Layout(engine, second).GetBoundingClientRect(A(second)).Width); Assert.Equal(2, engine.CssParseGeneration);
A(second).SetAttribute("style", "height:30px"); Assert.Equal(30, Layout(engine, second).GetBoundingClientRect(A(second)).Height); Assert.Equal(1, engine.CssReuseCount);
}
[Fact]
public void CspToggleCannotReuseAuthorRulesAcrossDisabledStyles()
{
var page = Page("#A{width:100px}"); var engine = new BrowserLayoutEngine(); Layout(engine, page);
var blocked = engine.Layout(page, new(800, 600), blockAuthorStyles: true); Assert.Equal(784, blocked.GetBoundingClientRect(A(page)).Width);
Assert.Contains(blocked.Diagnostics, message => message.Contains("CSP")); Assert.Equal(100, Layout(engine, page).GetBoundingClientRect(A(page)).Width);
}
[Fact]
public void CacheResetsInlineDeclarationAndSelectorBudgetsPerLayoutButStillRejectsExcess()
{
var page = Page("#A{width:100px}", "<div id=A style='height:20px'>X</div>");
var engine = new BrowserLayoutEngine(new() { MaximumDeclarations = 2, MaximumSelectorEvaluations = 5 });
for (int count = 0; count < 10; count++) Assert.Equal(20, Layout(engine, page).GetBoundingClientRect(A(page)).Height);
Assert.Equal(1, engine.CssParseGeneration);
A(page).SetAttribute("style", "height:20px;color:red"); Assert.Throws<BrowserLimitException>(() => Layout(engine, page));
// The subject index correctly skips #A for other elements. Use a
// universal rule to exercise the actual matcher-work budget.
page.QuerySelector("style")!.TextContent = "*{width:100px}";
A(page).SetAttribute("style", "height:20px"); A(page).AppendChild(page.CreateElement("span")); Assert.Throws<BrowserLimitException>(() => Layout(engine, page));
}
[Fact]
public void ChangedCssStillChecksSourceCharacterLimitAndParseDiagnosticsReplay()
{
var page = Page("#A{filter:none;width:100px}"); var engine = new BrowserLayoutEngine(new() { MaximumCssCharacters = 60 });
var first = Layout(engine, page); var cached = Layout(engine, page);
Assert.Contains(first.Diagnostics, message => message.Contains("filter")); Assert.Contains(cached.Diagnostics, message => message.Contains("filter"));
((IText)page.GetElementById("Css")!.FirstChild!).Data = "/*" + new string('x', 100) + "*/";
Assert.Throws<BrowserLimitException>(() => Layout(engine, page));
}
[Fact]
public void CachedLayoutDoesNotReallocateLargeUnchangedStylesheetCopies()
{
// Keep substantial real parse work after comment scanning no longer
// copies the entire source: cache reuse must avoid compiling these rules.
string rules=string.Concat(Enumerable.Range(0,512).Select(index=>$".Unused{index}{{width:100px;height:20px}}"));
var page = Page("/*" + new string('x', 150000) + "*/"+rules+"#A{width:100px}"); var engine = new BrowserLayoutEngine();
Layout(engine, page); Layout(engine, page);
long before = GC.GetAllocatedBytesForCurrentThread(); Layout(engine, page); long cached = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread(); Layout(new BrowserLayoutEngine(), page); long fresh = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(cached < fresh / 4, $"Unchanged cache allocated {cached}; fresh parse allocated {fresh}."); Assert.Equal(1, engine.CssParseGeneration);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ReplacedNodesAreNotRetainedByPersistentEngineAndEarlierGeometryStillWorks(bool suppliedIdentity)
{
var page = Page("#A{width:100px;height:20px}"); var engine = new BrowserLayoutEngine();
int next = 100;
Func<INode, int>? identity = suppliedIdentity ? _ => ++next : null;
var first = engine.Layout(page, new(800, 600), nodeIdentity: identity);
var previous = A(page); int expected = engine.RetainedNodeIdentityCount;
int bodyId = Assert.Single(first.Boxes, box => ReferenceEquals(box.Node, page.Body)).NodeId;
for (int iteration = 0; iteration < 100; iteration++)
{
var replacement = page.CreateElement("div"); replacement.Id = "A"; replacement.TextContent = "Replacement";
A(page).Remove(); page.Body!.AppendChild(replacement);
var current = engine.Layout(page, new(800, 600), nodeIdentity: identity);
Assert.Equal(expected, engine.RetainedNodeIdentityCount);
Assert.Equal(bodyId, Assert.Single(current.Boxes, box => ReferenceEquals(box.Node, page.Body)).NodeId);
Assert.Equal(default, current.GetBoundingClientRect(previous));
}
Assert.Equal(100, first.GetBoundingClientRect(previous).Width);
Assert.Equal(1, engine.CssParseGeneration);
}
[Fact]
public void FailedDomBudgetAlsoPrunesReplacedNodeIdentityReferences()
{
var page = Page("#A{width:100px}"); var engine = new BrowserLayoutEngine(new() { MaximumNodes = 8 });
Layout(engine, page); var previous = A(page);
page.Body!.InnerHtml = string.Concat(Enumerable.Repeat("<div>New</div>", 20));
Assert.Throws<BrowserLimitException>(() => Layout(engine, page));
Assert.InRange(engine.RetainedNodeIdentityCount, 0, 8);
page.Body.InnerHtml = "<div id=A>Recovered</div>";
var current = Layout(engine, page);
Assert.Equal(default, current.GetBoundingClientRect(previous)); Assert.Equal(100, current.GetBoundingClientRect(A(page)).Width);
Assert.Equal(7, engine.RetainedNodeIdentityCount);
}
}
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
namespace XFESystem.Browser.Tests;
public sealed class BrowserCssCacheTests
{
private static IDocument Page(string css, string content = "<div id=A>Code</div>") =>
new HtmlParser().ParseDocument("<style id=Css>" + css + "</style>" + content);
private static BrowserDisplayList Layout(BrowserLayoutEngine engine, IDocument page, double width = 800, double height = 600) => engine.Layout(page, new(width, height));
private static IElement A(IDocument page) => page.GetElementById("A")!;
[Fact]
public void RepeatedLayoutReusesAuthorRulesWhileCurrentDomStylesStillChange()
{
var page = Page("#A{width:100px;height:20px}"); var engine = new BrowserLayoutEngine();
Layout(engine, page); A(page).SetAttribute("style", "width:180px"); var next = Layout(engine, page);
Assert.Equal(180, next.GetBoundingClientRect(A(page)).Width); Assert.Equal(1, engine.CssParseGeneration); Assert.Equal(1, engine.CssReuseCount);
}
[Fact]
public void TextDataMutationInvalidatesCachedCascadeWithoutElementTextContentConcatenation()
{
var page = Page("#A{width:100px}"); var engine = new BrowserLayoutEngine(); Layout(engine, page);
((IText)page.GetElementById("Css")!.FirstChild!).Data = "#A{width:177px}";
Assert.Equal(177, Layout(engine, page).GetBoundingClientRect(A(page)).Width); Assert.Equal(2, engine.CssParseGeneration);
}
[Fact]
public void MultiNodeStyleTextPreservesConcatenationAndInvalidatesChangesAndOrder()
{
var page = Page(""); var css = page.GetElementById("Css")!;
css.AppendChild(page.CreateTextNode("#A{width:")); var value = page.CreateTextNode("100px"); css.AppendChild(value); css.AppendChild(page.CreateComment("ignored")); css.AppendChild(page.CreateTextNode("}"));
var engine = new BrowserLayoutEngine(); Assert.Equal(100, Layout(engine, page).GetBoundingClientRect(A(page)).Width);
Assert.Equal(100, Layout(engine, page).GetBoundingClientRect(A(page)).Width); Assert.Equal(1, engine.CssReuseCount);
value.Data = "188px"; Assert.Equal(188, Layout(engine, page).GetBoundingClientRect(A(page)).Width); Assert.Equal(2, engine.CssParseGeneration);
}
[Fact]
public void LinkedOwnerRemovalAndAuthorOrderChangeRebuildActualCascade()
{
var page = new HtmlParser().ParseDocument("<style>#A{width:100px}</style><link id=Link rel=stylesheet><style id=Last>#A{width:300px}</style><div id=A></div>");
var link = page.GetElementById("Link")!; var last = page.GetElementById("Last")!; var sheets = new[] { new BrowserStyleSheet("#A{width:200px}", link) };
var engine = new BrowserLayoutEngine(); Assert.Equal(300, engine.Layout(page, new(800, 600), sheets).GetBoundingClientRect(A(page)).Width);
page.Head!.InsertBefore(last, link); Assert.Equal(200, engine.Layout(page, new(800, 600), sheets).GetBoundingClientRect(A(page)).Width);
link.Remove(); Assert.Equal(300, engine.Layout(page, new(800, 600), sheets).GetBoundingClientRect(A(page)).Width); Assert.Equal(3, engine.CssParseGeneration);
}
[Fact]
public void WidthAndHeightMediaConditionsAndViewportUnitsUseCurrentViewport()
{
var page = Page("#A{width:100px;height:10vh}@media(min-width:600px){#A{width:200px}}@media(min-height:500px){#A{width:300px}}");
var engine = new BrowserLayoutEngine(); Assert.Equal(300, Layout(engine, page).GetBoundingClientRect(A(page)).Width);
var shortPage = Layout(engine, page, 800, 400); Assert.Equal(200, shortPage.GetBoundingClientRect(A(page)).Width); Assert.Equal(40, shortPage.GetBoundingClientRect(A(page)).Height);
Assert.Equal(100, Layout(engine, page, 400, 400).GetBoundingClientRect(A(page)).Width); Assert.Equal(3, engine.CssParseGeneration);
}
[Fact]
public void CachedSelectorsReadLiveHoverActiveAndFocusStateWithoutReparsing()
{
var page = Page("#A{width:100px}#A:hover{width:150px}#A:active{width:180px}#A:focus{height:70px}"); var engine = new BrowserLayoutEngine();
Layout(engine, page); engine.SetInteractionState(A(page), null, null); Assert.Equal(150, Layout(engine, page).GetBoundingClientRect(A(page)).Width);
engine.SetInteractionState(A(page), A(page), A(page)); var active = Layout(engine, page); Assert.Equal(180, active.GetBoundingClientRect(A(page)).Width); Assert.Equal(70, active.GetBoundingClientRect(A(page)).Height);
engine.SetInteractionState(null, null, null); Assert.Equal(100, Layout(engine, page).GetBoundingClientRect(A(page)).Width);
Assert.Equal(1, engine.CssParseGeneration); Assert.Equal(3, engine.CssReuseCount);
}
[Fact]
public void NavigationClearsPreviousDocumentOwnersAndInteractionState()
{
var page = Page("#A:hover{width:100px}"); var engine = new BrowserLayoutEngine(); engine.SetInteractionState(A(page), null, null); Layout(engine, page);
var second = Page("#A{width:180px}"); Assert.Equal(180, Layout(engine, second).GetBoundingClientRect(A(second)).Width); Assert.Equal(2, engine.CssParseGeneration);
A(second).SetAttribute("style", "height:30px"); Assert.Equal(30, Layout(engine, second).GetBoundingClientRect(A(second)).Height); Assert.Equal(1, engine.CssReuseCount);
}
[Fact]
public void CspToggleCannotReuseAuthorRulesAcrossDisabledStyles()
{
var page = Page("#A{width:100px}"); var engine = new BrowserLayoutEngine(); Layout(engine, page);
var blocked = engine.Layout(page, new(800, 600), blockAuthorStyles: true); Assert.Equal(784, blocked.GetBoundingClientRect(A(page)).Width);
Assert.Contains(blocked.Diagnostics, message => message.Contains("CSP")); Assert.Equal(100, Layout(engine, page).GetBoundingClientRect(A(page)).Width);
}
[Fact]
public void CacheResetsInlineDeclarationAndSelectorBudgetsPerLayoutButStillRejectsExcess()
{
var page = Page("#A{width:100px}", "<div id=A style='height:20px'>X</div>");
var engine = new BrowserLayoutEngine(new() { MaximumDeclarations = 2, MaximumSelectorEvaluations = 5 });
for (int count = 0; count < 10; count++) Assert.Equal(20, Layout(engine, page).GetBoundingClientRect(A(page)).Height);
Assert.Equal(1, engine.CssParseGeneration);
A(page).SetAttribute("style", "height:20px;color:red"); Assert.Throws<BrowserLimitException>(() => Layout(engine, page));
// The subject index correctly skips #A for other elements. Use a
// universal rule to exercise the actual matcher-work budget.
page.QuerySelector("style")!.TextContent = "*{width:100px}";
A(page).SetAttribute("style", "height:20px"); A(page).AppendChild(page.CreateElement("span")); Assert.Throws<BrowserLimitException>(() => Layout(engine, page));
}
[Fact]
public void ChangedCssStillChecksSourceCharacterLimitAndParseDiagnosticsReplay()
{
var page = Page("#A{filter:none;width:100px}"); var engine = new BrowserLayoutEngine(new() { MaximumCssCharacters = 60 });
var first = Layout(engine, page); var cached = Layout(engine, page);
Assert.Contains(first.Diagnostics, message => message.Contains("filter")); Assert.Contains(cached.Diagnostics, message => message.Contains("filter"));
((IText)page.GetElementById("Css")!.FirstChild!).Data = "/*" + new string('x', 100) + "*/";
Assert.Throws<BrowserLimitException>(() => Layout(engine, page));
}
[Fact]
public void CachedLayoutDoesNotReallocateLargeUnchangedStylesheetCopies()
{
// Keep substantial real parse work after comment scanning no longer
// copies the entire source: cache reuse must avoid compiling these rules.
string rules=string.Concat(Enumerable.Range(0,512).Select(index=>$".Unused{index}{{width:100px;height:20px}}"));
var page = Page("/*" + new string('x', 150000) + "*/"+rules+"#A{width:100px}"); var engine = new BrowserLayoutEngine();
Layout(engine, page); Layout(engine, page);
long before = GC.GetAllocatedBytesForCurrentThread(); Layout(engine, page); long cached = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread(); Layout(new BrowserLayoutEngine(), page); long fresh = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(cached < fresh / 4, $"Unchanged cache allocated {cached}; fresh parse allocated {fresh}."); Assert.Equal(1, engine.CssParseGeneration);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ReplacedNodesAreNotRetainedByPersistentEngineAndEarlierGeometryStillWorks(bool suppliedIdentity)
{
var page = Page("#A{width:100px;height:20px}"); var engine = new BrowserLayoutEngine();
int next = 100;
Func<INode, int>? identity = suppliedIdentity ? _ => ++next : null;
var first = engine.Layout(page, new(800, 600), nodeIdentity: identity);
var previous = A(page); int expected = engine.RetainedNodeIdentityCount;
int bodyId = Assert.Single(first.Boxes, box => ReferenceEquals(box.Node, page.Body)).NodeId;
for (int iteration = 0; iteration < 100; iteration++)
{
var replacement = page.CreateElement("div"); replacement.Id = "A"; replacement.TextContent = "Replacement";
A(page).Remove(); page.Body!.AppendChild(replacement);
var current = engine.Layout(page, new(800, 600), nodeIdentity: identity);
Assert.Equal(expected, engine.RetainedNodeIdentityCount);
Assert.Equal(bodyId, Assert.Single(current.Boxes, box => ReferenceEquals(box.Node, page.Body)).NodeId);
Assert.Equal(default, current.GetBoundingClientRect(previous));
}
Assert.Equal(100, first.GetBoundingClientRect(previous).Width);
Assert.Equal(1, engine.CssParseGeneration);
}
[Fact]
public void FailedDomBudgetAlsoPrunesReplacedNodeIdentityReferences()
{
var page = Page("#A{width:100px}"); var engine = new BrowserLayoutEngine(new() { MaximumNodes = 8 });
Layout(engine, page); var previous = A(page);
page.Body!.InnerHtml = string.Concat(Enumerable.Repeat("<div>New</div>", 20));
Assert.Throws<BrowserLimitException>(() => Layout(engine, page));
Assert.InRange(engine.RetainedNodeIdentityCount, 0, 8);
page.Body.InnerHtml = "<div id=A>Recovered</div>";
var current = Layout(engine, page);
Assert.Equal(default, current.GetBoundingClientRect(previous)); Assert.Equal(100, current.GetBoundingClientRect(A(page)).Width);
Assert.Equal(7, engine.RetainedNodeIdentityCount);
}
}