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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
# 0.11.2

Released on Thursday, November 12 2026.

- Improved deeply nested pages: what is nested too deep for the stack is left out instead of ending the process with a stack overflow (#55) @michaelthyregod
- Improved the performance of nested tables and inline-blocks, which were laid out twice as often with every level (#54) @michaelthyregod
- Improved performance with partial layouting due to an off-screen lookahead which avoids full style-map creation
- Improved the first frame of pages with long flex containers: the lines of a wrapping row and the items of a column that start below the viewport are not laid out
- Improved the performance of flex rows: an item with a definite width only measures its content when its line has to shrink
- Improved the garbage collection while building style maps (about 20% less allocated per frame)
- Fixed adjacent text nodes (e.g. inserted by a script or a framework like Blazor) each starting a line of their own in a block, table cell or flex item: they are laid out as one text, like the single node the parser makes of the same text
- Fixed the intrinsic width of a row flex container counting each of adjacent text nodes as an item of its own (with a gap between them)

# 0.11.1

Released on Sunday, November 8 2026.
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Thanks :heart: to all who contributed to AngleSharp.Renderer via issues, pull re
AngleSharp.Renderer contains code written by (in order of first pull request / commit):

* [Florian Rappl](https://github.com/FlorianRappl)
* [Michael Thyregod](https://github.com/michaelthyregod)

Without these awesome people AngleSharp.Renderer could not exist. Thanks to everyone for your contributions! :beers:

Expand Down
46 changes: 46 additions & 0 deletions src/AngleSharp.Renderer.Benchmarks/BASELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,52 @@ a per-run-deterministic number and does not have this problem.

## History

### 2026-11-13, flex cutoffs below the viewport, deferred flex minimums, fewer allocations

Same probe as the entry below (Linux, .NET 10, Release, viewport 1280x800), but each result is
the median of the last 15 of 30 iterations. "Viewport" is `PrepareViewportFrame`, "full" is
`PrepareFrame`. The machine is shared, so a single run can be off by up to 70%; the numbers
below were repeated until they were stable.

| Fixture | Before | After |
|---|---:|---:|
| Dashboard (flex/grid cards), viewport | 329 ms | 81 ms |
| Dashboard, full frame | 296 ms / 81.6 MiB | 281 ms / 66.1 MiB |
| 1,500 fixed-height flex-column rows, viewport | 349 ms | 128 ms |
| 1,500 block rows, viewport | 165 ms | 98 ms |

- A wrapping flex row of an auto height stops laying out lines once a line starts below the
viewport, because its line positions depend only on the lines before it. A flex column that is
not reversed stops laying out items in the same case. A bottom-sticky box among the rest
disables both cutoffs, as it does in block flow.
- A row flex item with a definite width and an automatic `flex-basis` no longer measures its
min-content width up front, because its hypothetical size is its width. The automatic minimum
is resolved only for the items of a line that has to shrink. On the dashboard this halved the
text measurements, from 31,567 to 16,046.
- Allocation cuts in style-map parsing: span-based comma splitting, allocation-free pixel
parsing, fast paths for zero radii and for the initial `background-position`, and an early
return when there is no background image. They saved 15 MiB per dashboard frame with no
measurable change in time.

The pixels of all three fixtures are identical to before.

### 2026-11-12, viewport-only first-frame rendering

Measured on Linux with .NET SDK 10.0.100, Release, viewport 1280x800. A fresh document was
parsed before each timer; each result is the median of the final six iterations in a 12-iteration
process. These exploratory probe results are not BenchmarkDotNet confidence intervals; absolute
times vary on this shared machine.

| Fixture | Before: time / allocated | After: time / allocated | Work avoided |
|---|---:|---:|---|
| 1,500 block rows | 186 ms / 31.1 MiB | 107 ms / 19.9 MiB | Style maps: 1,521 → 37 |
| 1,500 fixed-height flex-column rows | 495 ms / 111.9 MiB | 397 ms / 83.7 MiB | Trial layouts: 1,500 → 0; text measurements: 144,000 → 72,000 |

Both fixtures still laid out 37 and 3,003 boxes, respectively. The block result comes from avoiding
full style maps for off-screen siblings during lookahead; the flex result avoids trial-measuring
definite item heights before their actual layout. The viewport test suite separately checks pixel
equivalence and parallel-vs-sequential style preparation.

### 2026-09-11, initial baseline (before any optimization)

| Method (net10.0) | Mean | Allocated |
Expand Down
2 changes: 1 addition & 1 deletion src/AngleSharp.Renderer.Docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anglesharp/renderer",
"version": "0.11.1",
"version": "0.11.2",
"preview": true,
"description": "The doclet for the AngleSharp.Renderer documentation.",
"keywords": [
Expand Down
70 changes: 70 additions & 0 deletions src/AngleSharp.Renderer.Tests/DeepNestingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
namespace AngleSharp.Renderer.Tests;

using System.Text;
using System.Threading;
using AngleSharp;
using AngleSharp.Css;

/// <summary>
/// Deeply nested documents: layout recurses per nesting level, so a deep enough tree must leave
/// its innermost content out rather than overflow the stack (which ends the whole process).
/// </summary>
public sealed class DeepNestingTests
{
private static readonly DefaultRenderDevice Device = new() { ViewPortWidth = 300, ViewPortHeight = 200, FontSize = 16 };

// The HTML parser keeps at most 511 levels below the body, so this is the deepest markup can get.
private const int ParserDepthLimit = 511;

// A thread-pool thread or a desktop app's thread commonly has 1 MB of stack.
private const int StackSize = 1024 * 1024;

[Theory]
[InlineData("<div>", "</div>")]
[InlineData("<span>x", "</span>")]
[InlineData("<div style='display: flex'>", "</div>")]
[InlineData("<div style='display: grid'>", "</div>")]
[InlineData("<div style='display: inline-block'>", "</div>")]
[InlineData("<div style='float: left'>", "</div>")]
[InlineData("<table><tr><td>", "</td></tr></table>")]
public async Task DeepestParsableNesting_RendersWithoutOverflowingTheStack(string open, string close)
{
var markup = new StringBuilder("<!doctype html><html><body>");

for (var i = 0; i < ParserDepthLimit; i++)
{
markup.Append(open);
}

markup.Append("leaf");

for (var i = 0; i < ParserDepthLimit; i++)
{
markup.Append(close);
}

markup.Append("</body></html>");
var context = BrowsingContext.New(Configuration.Default.WithCss().WithRenderDevice(Device));
var document = await context.OpenAsync(request => request.Content(markup.ToString()));
byte[]? png = null;
Exception? error = null;
var thread = new Thread(() =>
{
try
{
png = new HtmlRenderer().RenderToPng(document, Device).Data;
}
catch (Exception ex)
{
error = ex;
}
}, StackSize);

thread.Start();
thread.Join();

Assert.Null(error);
Assert.NotNull(png);
Assert.NotEmpty(png);
}
}
21 changes: 21 additions & 0 deletions src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9214,6 +9214,27 @@ public async Task BuildDisplayList_FlexItemsDoNotShrinkBelowTheirContentUnlessAl
Assert.Equal(expectedWidth, SingleFill(BuildDisplayList(document), Red).Rect.Width, precision: 2);
}

[Theory]
[InlineData("nowrap", "width:100px;", 80f)]
[InlineData("nowrap", "width:100px; min-width:0;", 50f)]
[InlineData("nowrap", "width:100px; overflow:hidden;", 50f)]
[InlineData("nowrap", "width:30px;", 30f)]
[InlineData("wrap", "width:150px;", 100f)]
[InlineData("wrap", "width:40px;", 40f)]
public async Task BuildDisplayList_FlexItemsWithADefiniteWidthShrinkOnlyToTheirAutomaticMinimum(string wrap, string style, float expectedWidth)
{
var document = await ParseAsync($"""
<html><body style="margin:0">
<div style="display:flex; flex-wrap:{wrap}; width:100px">
<div style="{style} background-color:#ff0000"><span style="display:inline-block; width:80px; height:10px"></span></div>
<div style="{style} background-color:#00ff00"><span style="display:inline-block; width:20px; height:10px"></span></div>
</div>
</body></html>
""");

Assert.Equal(expectedWidth, SingleFill(BuildDisplayList(document), Red).Rect.Width, precision: 2);
}

[Fact]
public async Task BuildDisplayList_FlexShrinkIsWeightedByTheBaseSize()
{
Expand Down
48 changes: 48 additions & 0 deletions src/AngleSharp.Renderer.Tests/InteractiveFrameTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
namespace AngleSharp.Renderer.Tests;

using System.Runtime.ExceptionServices;

using AngleSharp;
using AngleSharp.Css;
using AngleSharp.Dom;
Expand Down Expand Up @@ -290,6 +292,52 @@ public async Task Frame_HitsAnInlineBoxOnSeveralLinesOnlyOnItsLines()
Assert.Equal("box", document.ElementFromPoint(first.Left / 2, (first.Top + first.Bottom) / 2)?.Id);
}

[Theory]
[InlineData("<table><tr><td>", "</td></tr></table>")]
[InlineData("<div style='display: inline-block'>", "</div>")]
public async Task NestedTrialLayouts_GrowLinearlyWithTheDepth(string open, string close)
{
// Every level measures its content with a trial layout; were each level to lay out the
// levels below it again, doubling the depth would multiply the trials by about 2^6.
var shallow = await CountTrialLayoutsAsync(open, close, depth: 6);
var deep = await CountTrialLayoutsAsync(open, close, depth: 12);

Assert.True(deep <= 3 * shallow, $"{shallow} trial layouts at depth 6, {deep} at depth 12");
}

private const int NestedLayoutStackSize = 16 * 1024 * 1024;

private static async Task<int> CountTrialLayoutsAsync(string open, string close, int depth)
{
var body = string.Concat(Enumerable.Repeat(open, depth)) + "leaf" + string.Concat(Enumerable.Repeat(close, depth));
var (_, harness) = await OpenAsync(body);
var trials = 0;
Exception? error = null;
// Laid out only a few times, the layout stays in the first JIT tier, whose frames are
// large: 12 levels need more stack than test threads have on some platforms (macOS).
var thread = new Thread(() =>
{
try
{
trials = harness.PrepareFrame().Metrics.TrialLayouts;
}
catch (Exception ex)
{
error = ex;
}
}, NestedLayoutStackSize);

thread.Start();
thread.Join();

if (error is not null)
{
ExceptionDispatchInfo.Capture(error).Throw();
}

return trials;
}

private static async Task<(IDocument Document, IDomHarness Harness)> OpenAsync(string body)
{
var configuration = Configuration.Default.WithCss().WithRenderDevice(Device);
Expand Down
32 changes: 32 additions & 0 deletions src/AngleSharp.Renderer.Tests/ParallelLayoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,29 @@ public async Task CreatingStyleMapsInParallelGivesTheSameFrame()
}
}

[Fact]
public async Task OnDemandFlexItemStylesCanBeCreatedInParallel()
{
var items = string.Concat(Enumerable.Repeat(
"""<div style="height: 40px; border: 1px solid #ccc">A fixed-height flex item</div>""", 240));

var parallel = await LayOutViewportAsync(items);
AppContext.SetSwitch(Switch, true);

try
{
var sequential = await LayOutViewportAsync(items);

Assert.True(parallel.IsPartial);
Assert.Equal(sequential.Boxes, parallel.Boxes);
Assert.Equal(sequential.Pixels, parallel.Pixels);
}
finally
{
AppContext.SetSwitch(Switch, false);
}
}

private static async Task<(int Elements, string Boxes, byte[] Pixels)> LayOutAsync(string body)
{
var context = BrowsingContext.New(Configuration.Default.WithCss().WithRenderDevice(Device));
Expand All @@ -64,4 +87,13 @@ public async Task CreatingStyleMapsInParallelGivesTheSameFrame()
var boxes = string.Join('\n', document.All.Select(element => element.GetBoundingClientRect()).Select(rect => $"{rect.Left} {rect.Top} {rect.Width} {rect.Height}"));
return (document.All.Length, boxes, pixels);
}

private static async Task<(bool IsPartial, int Boxes, byte[] Pixels)> LayOutViewportAsync(string items)
{
var context = BrowsingContext.New(Configuration.Default.WithCss().WithRenderDevice(Device));
var document = await context.OpenAsync(request => request.Content(
$"""<html><body><main style="display: flex; flex-direction: column">{items}</main></body></html>"""));
var frame = document.Context.GetDomHarness().PrepareViewportFrame();
return (frame.IsPartial, frame.Metrics.Boxes, frame.Rasterize().Data);
}
}
93 changes: 93 additions & 0 deletions src/AngleSharp.Renderer.Tests/TextRunLayoutTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using AngleSharp;
using AngleSharp.Css;
using AngleSharp.Dom;

namespace AngleSharp.Renderer.Tests;

/// <summary>
/// Adjacent text nodes, as scripts and frameworks (e.g. Blazor) insert them, are laid out like
/// the single text node the parser would have made of the same text.
/// </summary>
public sealed class TextRunLayoutTests
{
private const string Page = """
<html>
<head><style>html, body { margin: 0; padding: 0; } p { margin: 0; } td { padding: 0; }</style></head>
<body>
<p id="p"></p>
<table style="border-collapse: collapse"><tr><td id="td"></td></tr></table>
<div style="display:flex; gap:20px"><span id="flex"></span></div>
<div style="display:flex; flex-direction:column"><p id="column"></p></div>
<div id="inline-flex" style="display:inline-flex; gap:20px"></div>
</body>
</html>
""";

[Theory]
[InlineData("p")]
[InlineData("td")]
[InlineData("flex")]
[InlineData("column")]
[InlineData("inline-flex")]
public async Task AdjacentTextNodes_AreLaidOutAsOneText(string id)
{
var single = await LayoutAsync(id, "Picture 4 of 4");
var split = await LayoutAsync(id, "Picture ", "4", " of 4");

Assert.Equal(single.Width, split.Width, 3);
Assert.Equal(single.Height, split.Height, 3);
}

[Fact]
public async Task AdjacentTextNodes_AroundACommentAreOneText()
{
var single = await LayoutAsync("p", "Loaded at 10:42");
var document = await ParseAsync();
var p = document.GetElementById("p")!;
p.AppendChild(document.CreateTextNode("Loaded at "));
p.AppendChild(document.CreateComment("marker"));
p.AppendChild(document.CreateTextNode("10:42"));
document.Context.GetDomHarness().PaintToPng();

Assert.Equal(single.Height, p.GetBoundingClientRect().Height, 3);
}

[Fact]
public async Task AdjacentTextNodes_AfterWhiteSpaceOnlyNodeKeepTheirText()
{
var single = await LayoutAsync("p", "Picture 4 of 4");
var split = await LayoutAsync("p", " ", "Picture ", "4", " of 4");

Assert.Equal(single.Height, split.Height, 3);
}

private static async Task<(double Width, double Height)> LayoutAsync(string id, params string[] texts)
{
var document = await ParseAsync();
var element = document.GetElementById(id)!;

foreach (var text in texts)
{
element.AppendChild(document.CreateTextNode(text));
}

document.Context.GetDomHarness().PaintToPng();
var rect = element.GetBoundingClientRect();
return (rect.Width, rect.Height);
}

private static async Task<IDocument> ParseAsync()
{
var context = BrowsingContext.New(Configuration.Default
.WithCss()
.WithRenderDevice(new DefaultRenderDevice
{
ViewPortWidth = 400,
ViewPortHeight = 300,
DeviceWidth = 400,
DeviceHeight = 300,
FontSize = 16,
}));
return await context.OpenAsync(request => request.Content(Page));
}
}
Loading
Loading