From 1af4c3354210f2edfd274c19b11b094adc0f3da7 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 30 Jul 2026 21:43:02 +0000
Subject: [PATCH] perf: HtmlDocument.ToString reuses one StringBuilder across
all child elements
HtmlDocument.ToString() previously called element.ToString() for each
top-level element, which created a new StringBuilder, serialised the
node into it, extracted the intermediate string, and then appended that
string to the document-level StringBuilder. For pages with N root
elements this meant N extra string allocations and N extra copy passes.
Fix: extract the serialisation logic into an internal SerializeTo(sb,...)
method on HtmlNode so the document can pass its own StringBuilder
directly. HtmlNode.ToString() creates the StringBuilder and delegates,
preserving the public API unchanged.
All 3926 tests pass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/FSharp.Data.Html.Core/HtmlNode.fs | 35 +++++++++++++++------------
1 file changed, 20 insertions(+), 15 deletions(-)
diff --git a/src/FSharp.Data.Html.Core/HtmlNode.fs b/src/FSharp.Data.Html.Core/HtmlNode.fs
index 217a1c8ee..29a608d99 100644
--- a/src/FSharp.Data.Html.Core/HtmlNode.fs
+++ b/src/FSharp.Data.Html.Core/HtmlNode.fs
@@ -118,8 +118,9 @@ type HtmlNode =
/// The actual content
static member NewCData content = HtmlCData(content)
- override x.ToString() =
- let sb = StringBuilder()
+ /// Serialize this node into an existing StringBuilder.
+ /// Used by HtmlDocument.ToString() to avoid creating an intermediate string per element.
+ member internal x.SerializeTo(sb: StringBuilder, indentation: int, canAddNewLine: bool, insidePre: bool) =
let append (str: string) = sb.Append str |> ignore
let appendEndTag name =
@@ -127,16 +128,16 @@ type HtmlNode =
append name
append ">"
- let newLine indentation plus =
+ let newLine ind plus =
sb.AppendLine() |> ignore
- sb.Append(' ', indentation + plus) |> ignore
+ sb.Append(' ', ind + plus) |> ignore
// serialization uses an explicit work stack instead of call-stack recursion:
// the parser accepts arbitrarily deep documents (it uses its own stack), so
// ToString must not StackOverflow on them either
let work = System.Collections.Generic.Stack unit>()
- let rec serialize indentation canAddNewLine insidePre html =
+ let rec serialize ind canAdd inPre html =
match html with
| HtmlElement(name, attributes, elements) ->
let onlyText =
@@ -146,10 +147,10 @@ type HtmlNode =
| _ -> false)
let isPreTag = name = "pre"
- let nowInsidePre = insidePre || isPreTag
+ let nowInsidePre = inPre || isPreTag
- if canAddNewLine && not insidePre && not (onlyText || isPreTag) then
- newLine indentation 0
+ if canAdd && not inPre && not (onlyText || isPreTag) then
+ newLine ind 0
append "<"
append name
@@ -169,13 +170,13 @@ type HtmlNode =
else
append ">"
- if not insidePre && not (onlyText || isPreTag) then
- newLine indentation 2
+ if not inPre && not (onlyText || isPreTag) then
+ newLine ind 2
// pushed first so it runs after all children are processed
work.Push(fun () ->
- if not insidePre && not (onlyText || isPreTag) then
- newLine indentation 0
+ if not inPre && not (onlyText || isPreTag) then
+ newLine ind 0
appendEndTag name)
@@ -186,7 +187,7 @@ type HtmlNode =
for i in elements.Length - 1 .. -1 .. 0 do
let element = elements.[i]
let canAddNewLine = i > 0
- work.Push(fun () -> serialize (indentation + 2) canAddNewLine nowInsidePre element)
+ work.Push(fun () -> serialize (ind + 2) canAddNewLine nowInsidePre element)
| HtmlText str -> append str
| HtmlComment str ->
append "