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; }