Skip to content

Commit cc28a88

Browse files
authored
Merge branch 'main' into repo-assist/perf-htmlnode-serialize-2026-04-29-3c3cf8d8a964a909
2 parents 3d1afd9 + af42ebe commit cc28a88

10 files changed

Lines changed: 526 additions & 34 deletions

File tree

RELEASE_NOTES.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
# Release Notes
22

3-
## 8.1.12 - Apr 29 2026
3+
## 8.1.13 - May 08 2026
44

55
- Performance: `HtmlNode.serialize` no longer allocates a temporary `string` on each newline/indentation step; uses `StringBuilder.Append(char, int)` overload directly. `isVoidElement` set is now computed once at module initialisation instead of being re-created on every `HtmlNode.ToString()` call. `HtmlDocument.ToString()` uses a single `StringBuilder` for the whole document instead of `List.map … |> String.Concat`.
66

7+
## 8.1.12 - Apr 29 2026
8+
9+
- Fix: `JsonValue.WriteTo` now always uses `CultureInfo.InvariantCulture` when serializing `Number` (decimal) values, preventing invalid JSON output (e.g. `1,5` instead of `1.5`) when called with a `TextWriter` configured with a non-English culture.
10+
- Perf: `JsonValue.WriteTo` no longer allocates an intermediate `System.String(' ', n)` per indentation level; spaces are written directly to the writer.
11+
712
## 8.1.11 - Apr 22 2026
813

914
- Code: `HtmlParser` `EmitTag` removes dead code in the `else` branch (the expression `x.HasFormattedParent || x.IsFormattedTag` was always equivalent to `x.HasFormattedParent` since `x.IsFormattedTag` is always `false` in that branch). Uses `name` directly to avoid re-computing `CurrentTagName()` for formatted/script tag checks. Also removes redundant `.ToLowerInvariant()` calls in `IsFormattedTag` and `IsScriptTag` since tag names are already lowercased at read time.

src/FSharp.Data.Html.Core/FSharp.Data.Html.Core.fsproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<Project Sdk="Microsoft.NET.Sdk">
33
<PropertyGroup>
44
<OutputType>Library</OutputType>
5-
<TargetFramework>netstandard2.0</TargetFramework>
5+
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
66
<OtherFlags>$(OtherFlags) --warnon:1182 --nowarn:10001 --nowarn:44</OtherFlags>
77
<GenerateDocumentationFile>true</GenerateDocumentationFile>
88
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>

src/FSharp.Data.Http/FSharp.Data.Http.fsproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<Project Sdk="Microsoft.NET.Sdk">
33
<PropertyGroup>
44
<OutputType>Library</OutputType>
5-
<TargetFramework>netstandard2.0</TargetFramework>
5+
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
66
<OtherFlags>$(OtherFlags) --warnon:1182 --nowarn:10001 --nowarn:44</OtherFlags>
77
<GenerateDocumentationFile>true</GenerateDocumentationFile>
88
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>

src/FSharp.Data.Json.Core/JsonValue.fs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,16 @@ type JsonValue =
7373
member x.WriteTo(w: TextWriter, saveOptions, ?indentationSpaces: int) =
7474
let indentationSpaces = defaultArg indentationSpaces 2
7575

76+
// Write `count` space characters without allocating an intermediate string.
77+
let inline writeSpaces count =
78+
for _ = 1 to count do
79+
w.Write(' ')
80+
7681
let newLine =
7782
if saveOptions = JsonSaveOptions.None then
7883
fun indentation plus ->
7984
w.WriteLine()
80-
System.String(' ', indentation + plus) |> w.Write
85+
writeSpaces (indentation + plus)
8186
else
8287
fun _ _ -> ()
8388

@@ -94,7 +99,7 @@ type JsonValue =
9499
function
95100
| Null -> w.Write "null"
96101
| Boolean b -> w.Write(if b then "true" else "false")
97-
| Number number -> w.Write number
102+
| Number number -> w.Write(number.ToString(CultureInfo.InvariantCulture))
98103
| Float v when Double.IsInfinity v || Double.IsNaN v -> w.Write "null"
99104
| Float number ->
100105
let s = number.ToString("R", CultureInfo.InvariantCulture)

src/FSharp.Data.WorldBank.Core/FSharp.Data.WorldBank.Core.fsproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<Project Sdk="Microsoft.NET.Sdk">
33
<PropertyGroup>
44
<OutputType>Library</OutputType>
5-
<TargetFramework>netstandard2.0</TargetFramework>
5+
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
66
<OtherFlags>$(OtherFlags) --warnon:1182 --nowarn:10001</OtherFlags>
77
<GenerateDocumentationFile>true</GenerateDocumentationFile>
88
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>

tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
<Compile Include="JsonDocument.fs" />
3636
<Compile Include="JsonRuntime.fs" />
3737
<Compile Include="JsonSchema.fs" />
38+
<Compile Include="JsonValueOptionExtensions.fs" />
3839
<Compile Include="CsvReader.fs" />
3940
<Compile Include="CsvFile.fs" />
4041
<Compile Include="CsvRuntimeOperations.fs" />

tests/FSharp.Data.Core.Tests/JsonValue.fs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,3 +859,31 @@ let ``JsonValue WriteTo with None (default) produces indented output`` () =
859859
let result = writer.ToString()
860860
result.Contains("\n") |> should equal true
861861
result.Contains(" ") |> should equal true
862+
863+
[<Test>]
864+
let ``JsonValue WriteTo serializes decimals using InvariantCulture regardless of thread culture`` () =
865+
// In cultures that use ',' as decimal separator (e.g. de-DE), TextWriter.Write(decimal)
866+
// could produce invalid JSON like {"price":1,5} instead of {"price":1.5}.
867+
// WriteTo must always use InvariantCulture for decimal numbers.
868+
use _holder = withCulture "de-DE"
869+
let json = JsonValue.Record [| "price", JsonValue.Number 1.5M |]
870+
use writer = new System.IO.StringWriter()
871+
json.WriteTo(writer, JsonSaveOptions.DisableFormatting)
872+
let result = writer.ToString()
873+
result |> should equal """{"price":1.5}"""
874+
875+
[<Test>]
876+
let ``JsonValue ToString serializes decimal array using InvariantCulture`` () =
877+
use _holder = withCulture "fr-FR"
878+
let json = JsonValue.Array [| JsonValue.Number 1.5M; JsonValue.Number 99.99M |]
879+
json.ToString(JsonSaveOptions.DisableFormatting)
880+
|> should equal "[1.5,99.99]"
881+
882+
[<Test>]
883+
let ``JsonValue WriteTo indentation uses correct number of spaces`` () =
884+
let json = JsonValue.Record [| "x", JsonValue.Number 1M |]
885+
use writer = new System.IO.StringWriter()
886+
json.WriteTo(writer, JsonSaveOptions.None, 4)
887+
let result = writer.ToString()
888+
// With 4-space indent, the property line should start with 4 spaces
889+
result |> should contain " \"x\""
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
module FSharp.Data.Tests.JsonValueInnerTextAndExtensions
2+
3+
open NUnit.Framework
4+
open FsUnit
5+
open System
6+
open System.Globalization
7+
open FSharp.Data
8+
open FSharp.Data.JsonExtensions
9+
10+
// ============================================================
11+
// JsonExtensions.InnerText — C# [<Extension>] method
12+
// Returns AsString result for scalar values; concatenates array elements
13+
// ============================================================
14+
15+
[<Test>]
16+
let ``InnerText returns string content for a String value`` () =
17+
JsonValue.String("hello world").InnerText() |> should equal "hello world"
18+
19+
[<Test>]
20+
let ``InnerText returns empty string for Null`` () =
21+
JsonValue.Null.InnerText() |> should equal ""
22+
23+
[<Test>]
24+
let ``InnerText returns string representation for Boolean true`` () =
25+
JsonValue.Boolean(true).InnerText() |> should equal "true"
26+
27+
[<Test>]
28+
let ``InnerText returns string representation for Boolean false`` () =
29+
JsonValue.Boolean(false).InnerText() |> should equal "false"
30+
31+
[<Test>]
32+
let ``InnerText returns string representation for Number`` () =
33+
JsonValue.Number(42M).InnerText() |> should equal "42"
34+
35+
[<Test>]
36+
let ``InnerText returns string representation for Float`` () =
37+
JsonValue.Float(3.14).InnerText() |> should startWith "3.14"
38+
39+
[<Test>]
40+
let ``InnerText concatenates string elements of an array`` () =
41+
let json = JsonValue.Parse """["foo","bar","baz"]"""
42+
json.InnerText() |> should equal "foobarbaz"
43+
44+
[<Test>]
45+
let ``InnerText returns empty string for an empty array`` () =
46+
JsonValue.Array([||]).InnerText() |> should equal ""
47+
48+
[<Test>]
49+
let ``InnerText includes numeric elements in a mixed array`` () =
50+
let json = JsonValue.Parse """["hello", 42, "world"]"""
51+
json.InnerText() |> should equal "hello42world"
52+
53+
[<Test>]
54+
let ``InnerText returns empty string for a record object`` () =
55+
let json = JsonValue.Parse """{"key":"value"}"""
56+
json.InnerText() |> should equal ""
57+
58+
[<Test>]
59+
let ``InnerText handles Unicode strings correctly`` () =
60+
JsonValue.String("こんにちは").InnerText() |> should equal "こんにちは"
61+
62+
[<Test>]
63+
let ``InnerText handles empty string`` () =
64+
JsonValue.String("").InnerText() |> should equal ""
65+
66+
[<Test>]
67+
let ``InnerText concatenates array of strings`` () =
68+
let json = JsonValue.Parse """["a","b","c","d","e"]"""
69+
json.InnerText() |> should equal "abcde"
70+
71+
// ============================================================
72+
// JsonValue.Properties — F# augmentation member
73+
// ============================================================
74+
75+
[<Test>]
76+
let ``Properties returns all key-value pairs for a Record`` () =
77+
let json = JsonValue.Parse """{"x":1,"y":2,"z":3}"""
78+
let props = json.Properties
79+
props |> should haveLength 3
80+
props |> Array.map fst |> should equal [| "x"; "y"; "z" |]
81+
82+
[<Test>]
83+
let ``Properties returns empty array for an empty record`` () =
84+
JsonValue.Record([||]).Properties |> should haveLength 0
85+
86+
[<Test>]
87+
let ``Properties returns empty array for a non-record value`` () =
88+
JsonValue.String("text").Properties |> should haveLength 0
89+
JsonValue.Array([||]).Properties |> should haveLength 0
90+
JsonValue.Null.Properties |> should haveLength 0
91+
JsonValue.Boolean(false).Properties |> should haveLength 0
92+
93+
// ============================================================
94+
// AsGuid — additional edge cases
95+
// ============================================================
96+
97+
[<Test>]
98+
let ``AsGuid parses lowercase GUID string`` () =
99+
let j = JsonValue.Parse """{"id":"550e8400-e29b-41d4-a716-446655440000"}"""
100+
j?id.AsGuid() |> should equal (Guid.Parse "550e8400-e29b-41d4-a716-446655440000")
101+
102+
[<Test>]
103+
let ``AsGuid parses uppercase GUID string`` () =
104+
let j = JsonValue.Parse """{"id":"550E8400-E29B-41D4-A716-446655440000"}"""
105+
j?id.AsGuid() |> should equal (Guid.Parse "550E8400-E29B-41D4-A716-446655440000")
106+
107+
[<Test>]
108+
let ``AsGuid throws for invalid GUID string`` () =
109+
let j = JsonValue.Parse """{"id":"not-a-guid"}"""
110+
(fun () -> j?id.AsGuid() |> ignore) |> should throw typeof<Exception>
111+
112+
// ============================================================
113+
// AsBoolean — string-to-bool conversion cases
114+
// ============================================================
115+
116+
[<Test>]
117+
let ``AsBoolean parses true string`` () =
118+
let j = JsonValue.Parse """{"flag":"true"}"""
119+
j?flag.AsBoolean() |> should equal true
120+
121+
[<Test>]
122+
let ``AsBoolean parses false string`` () =
123+
let j = JsonValue.Parse """{"flag":"false"}"""
124+
j?flag.AsBoolean() |> should equal false
125+
126+
[<Test>]
127+
let ``AsBoolean parses yes/no strings`` () =
128+
let j = JsonValue.Parse """{"a":"yes","b":"no"}"""
129+
j?a.AsBoolean() |> should equal true
130+
j?b.AsBoolean() |> should equal false
131+
132+
[<Test>]
133+
let ``AsBoolean parses 1/0 strings`` () =
134+
let j = JsonValue.Parse """{"a":"1","b":"0"}"""
135+
j?a.AsBoolean() |> should equal true
136+
j?b.AsBoolean() |> should equal false
137+
138+
[<Test>]
139+
let ``AsBoolean throws for invalid boolean string`` () =
140+
let j = JsonValue.Parse """{"flag":"maybe"}"""
141+
(fun () -> j?flag.AsBoolean() |> ignore) |> should throw typeof<Exception>
142+
143+
// ============================================================
144+
// AsTimeSpan — additional edge cases
145+
// ============================================================
146+
147+
[<Test>]
148+
let ``AsTimeSpan throws for non-timespan value`` () =
149+
let j = JsonValue.Parse """{"d":"not-a-timespan"}"""
150+
(fun () -> j?d.AsTimeSpan() |> ignore) |> should throw typeof<Exception>
151+
152+
[<Test>]
153+
let ``AsTimeSpan parses zero duration`` () =
154+
// Uses .NET TimeSpan.Parse format: "0:00:00"
155+
let j = JsonValue.Parse """{"d":"0:00:00"}"""
156+
j?d.AsTimeSpan() |> should equal TimeSpan.Zero
157+
158+
// ============================================================
159+
// Dynamic operator (?) edge cases
160+
// ============================================================
161+
162+
[<Test>]
163+
let ``Dynamic operator accesses nested string property`` () =
164+
let j = JsonValue.Parse """{"person":{"name":"Alice"}}"""
165+
j?person?name.AsString() |> should equal "Alice"
166+
167+
[<Test>]
168+
let ``Dynamic operator throws for missing property`` () =
169+
let j = JsonValue.Parse """{"x":1}"""
170+
(fun () -> j?missing |> ignore) |> should throw typeof<Exception>

0 commit comments

Comments
 (0)