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 @@ -2,6 +2,7 @@

Released on Wednesday, November 11 2026.

- Improved the performance of nested tables and inline-blocks, which were laid out twice as often with every level (#54) @michaelthyregod
- 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)

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
2 changes: 2 additions & 0 deletions src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1805,6 +1805,8 @@ private void Forget(ElementRenderNode root)
}

RemoveKeys(_context.MeasuredHeights, key => elements.Contains(key.Element));
// a trial holds the layout of its whole subtree, so any change can make one stale
_context.TrialLayouts.Clear();
RemoveKeys(_context.GridItemHeights, key => elements.Contains(key.Element));

static void Collect(IRenderNode node, HashSet<IElement> elements)
Expand Down
100 changes: 99 additions & 1 deletion src/AngleSharp.Renderer/HtmlRenderer.Inline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ namespace AngleSharp.Renderer;

using System.Globalization;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;

using AngleSharp.Css.Dom;
Expand Down Expand Up @@ -2650,6 +2651,82 @@ _ when value.EndsWith('%') => Parse(value[..^1]) / 100f * lineHeight,
};
}

/// <summary>
/// What a trial layout of an element depends on: the style map it is laid out with (a
/// container in a writing-mode system of its own swaps it), the space and style it gets,
/// and how it is measured.
/// </summary>
private readonly record struct TrialLayoutKey(
Dictionary<string, string> StyleMap,
float ContainingWidth,
RenderTextStyle InheritedStyle,
float ForcedBorderBoxWidth,
bool IsAtomicInline,
bool IsFlexOrGridItem,
float MaxY,
bool IsHeightOnly);

private readonly record struct TrialLayoutResult(
ElementLayoutMetrics? Metrics,
float? LastBaselineY,
float? FirstBaselineY,
IReadOnlyDictionary<IElement, ElementLayoutMetrics> Laidout,
float? AutoContentHeight);

/// <summary>
/// A kept trial layout of an element, and the one kept before it for other inputs.
/// </summary>
private sealed class TrialLayoutEntry(TrialLayoutKey key)
{
public TrialLayoutKey Key { get; } = key;

public TrialLayoutResult Result { get; set; }

public TrialLayoutEntry? Next { get; set; }
}

/// <summary>
/// Finds the kept trial layout of an element for these inputs (<paramref name="found"/>), or
/// gives a new entry to keep the trial in when it runs inside another trial: only those are
/// repeated by the layout around them. Nothing is kept inside a line-clamped box, whose
/// lines are counted. Not inlined, so that the large inputs stay out of the frame of
/// <see cref="TrialLayout"/>, which is on the stack once per nesting level.
/// </summary>
[MethodImpl(MethodImplOptions.NoInlining)]
private static TrialLayoutEntry? FindTrialLayout(
ElementRenderNode element,
float containingWidth,
in RenderTextStyle inheritedStyle,
in LayoutContext context,
float forcedBorderBoxWidth,
bool isAtomicInline,
bool isFlexOrGridItem,
float maxY,
bool isHeightOnly,
out bool found)
{
found = false;

if (context.LineClampScopes.TryPeek(out _))
{
return null;
}

var key = new TrialLayoutKey(GetOrCreateStyleMap(context, element.Ref, element.ComputedStyle), containingWidth, inheritedStyle,
forcedBorderBoxWidth, isAtomicInline, isFlexOrGridItem, maxY, isHeightOnly);

for (var entry = context.TrialLayouts.GetValueOrDefault(element.Ref); entry is not null; entry = entry.Next)
{
if (entry.Key == key)
{
found = true;
return entry;
}
}

return s_trialDepth > 0 ? new TrialLayoutEntry(key) : null;
}

/// <summary>
/// Lays out <paramref name="element"/> at the origin into a throwaway display list, capturing
/// its own border box and the last text baseline painted inside it. Layout metrics recorded
Expand All @@ -2668,6 +2745,18 @@ private static (ElementLayoutMetrics? Metrics, float? LastBaselineY, float? Firs
float maxY = float.MaxValue,
bool isHeightOnly = false)
{
// A trial is laid out on its own (at the origin, with floats, positioned and sticky boxes
// of its own), so the same inputs give the same result within a render. Without reusing
// it, nested trials (cells of nested tables, nested inline-blocks) lay out every level
// again for each level above it (see FindTrialLayout).
var entry = FindTrialLayout(element, containingWidth, inheritedStyle, context, forcedBorderBoxWidth, isAtomicInline, isFlexOrGridItem, maxY, isHeightOnly, out var found);

if (found)
{
var cached = entry!.Result;
return (cached.Metrics, cached.LastBaselineY, cached.FirstBaselineY, cached.Laidout, cached.AutoContentHeight);
}

var pass = s_layoutPass.Value;
// a trial asking for more (e.g., the baseline of an inline-block) needs all of its boxes
var wasHeightOnly = s_measuringHeightOnly;
Expand Down Expand Up @@ -2727,7 +2816,16 @@ private static (ElementLayoutMetrics? Metrics, float? LastBaselineY, float? Firs
isFlexItem: isFlexOrGridItem);

var snapshot = capture.Snapshot();
return (snapshot.TryGetValue(element.Ref, out var metrics) ? metrics : null, capture.LastBaselineY, capture.FirstBaselineY, snapshot, capture.AutoContentHeights.TryGetValue(element.Ref, out var autoHeight) ? autoHeight : null);
var result = new TrialLayoutResult(snapshot.TryGetValue(element.Ref, out var metrics) ? metrics : null, capture.LastBaselineY, capture.FirstBaselineY, snapshot, capture.AutoContentHeights.TryGetValue(element.Ref, out var autoHeight) ? autoHeight : null);

if (entry is not null)
{
entry.Result = result;
entry.Next = context.TrialLayouts.GetValueOrDefault(element.Ref);
context.TrialLayouts[element.Ref] = entry;
}

return (result.Metrics, result.LastBaselineY, result.FirstBaselineY, result.Laidout, result.AutoContentHeight);
}
finally
{
Expand Down
4 changes: 4 additions & 0 deletions src/AngleSharp.Renderer/HtmlRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,10 @@ private readonly record struct LayoutContext(
// only needed for what the render tree does not hold (e.g. ::placeholder), so it is built on demand
public IStyleCollection? StyleCollection => LazyStyleCollection.Value;

// Trial layouts per element, for this render only (see TrialLayout). The inputs are
// large, so they are kept with each result rather than as keys of one big table.
public Dictionary<IElement, TrialLayoutEntry> TrialLayouts { get; } = new();

// the device the styles are computed for, and the collections of the shadow trees (see GetStyleCollection)
public IRenderDevice? Device { get; init; }

Expand Down
Loading