diff --git a/CHANGELOG.md b/CHANGELOG.md
index eb7fc8f..3d607a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/src/AngleSharp.Renderer.Tests/InteractiveFrameTests.cs b/src/AngleSharp.Renderer.Tests/InteractiveFrameTests.cs
index 61c0c22..70fdf60 100644
--- a/src/AngleSharp.Renderer.Tests/InteractiveFrameTests.cs
+++ b/src/AngleSharp.Renderer.Tests/InteractiveFrameTests.cs
@@ -1,5 +1,7 @@
namespace AngleSharp.Renderer.Tests;
+using System.Runtime.ExceptionServices;
+
using AngleSharp;
using AngleSharp.Css;
using AngleSharp.Dom;
@@ -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("
")]
+ [InlineData("", "
")]
+ 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 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);
diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs b/src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs
index 68c39aa..938067d 100644
--- a/src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs
+++ b/src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs
@@ -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 elements)
diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs b/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs
index 269e447..99afdf3 100644
--- a/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs
+++ b/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs
@@ -2,6 +2,7 @@ namespace AngleSharp.Renderer;
using System.Globalization;
using System.Linq;
+using System.Runtime.CompilerServices;
using System.Text;
using AngleSharp.Css.Dom;
@@ -2650,6 +2651,82 @@ _ when value.EndsWith('%') => Parse(value[..^1]) / 100f * lineHeight,
};
}
+ ///
+ /// 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.
+ ///
+ private readonly record struct TrialLayoutKey(
+ Dictionary 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 Laidout,
+ float? AutoContentHeight);
+
+ ///
+ /// A kept trial layout of an element, and the one kept before it for other inputs.
+ ///
+ private sealed class TrialLayoutEntry(TrialLayoutKey key)
+ {
+ public TrialLayoutKey Key { get; } = key;
+
+ public TrialLayoutResult Result { get; set; }
+
+ public TrialLayoutEntry? Next { get; set; }
+ }
+
+ ///
+ /// Finds the kept trial layout of an element for these inputs (), 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
+ /// , which is on the stack once per nesting level.
+ ///
+ [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;
+ }
+
///
/// Lays out at the origin into a throwaway display list, capturing
/// its own border box and the last text baseline painted inside it. Layout metrics recorded
@@ -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;
@@ -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
{
diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs
index eae739f..6820567 100644
--- a/src/AngleSharp.Renderer/HtmlRenderer.cs
+++ b/src/AngleSharp.Renderer/HtmlRenderer.cs
@@ -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 TrialLayouts { get; } = new();
+
// the device the styles are computed for, and the collections of the shadow trees (see GetStyleCollection)
public IRenderDevice? Device { get; init; }