From bddae98c1c3dbc233cd0840c1cb0783cc15b9ff0 Mon Sep 17 00:00:00 2001 From: Mangel Maxime Date: Sat, 14 Mar 2026 22:22:52 +0100 Subject: [PATCH 1/5] [JS/TS/Python] Fix record/value type constructor including static backing fields as parameters --- src/Fable.Cli/CHANGELOG.md | 1 + src/Fable.Compiler/CHANGELOG.md | 1 + src/Fable.Transforms/Fable2Babel.fs | 12 +- src/Fable.Transforms/FableTransforms.fs | 6 +- .../Python/Fable2Python.Transforms.fs | 18 ++- src/quicktest/QuickTest.fs | 104 +++++------------- tests/Beam/RecordTests.fs | 25 +++++ tests/Js/Main/RecordTypeTests.fs | 25 +++++ tests/Python/TestRecordType.fs | 29 ++++- 9 files changed, 134 insertions(+), 87 deletions(-) diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index f700a91832..e8b7067401 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* [JS/TS/Python] Fix record/struct types augmented with `static let` or `static member val` generating extra constructor parameters for each static field, causing constructor arguments to be assigned to wrong slots (by @MangelMaxime) * [TS] Annotate `System.Collections.Generic.IList` as `MutableArray` (by @MangelMaxime) * [JS/TS] Fix `ResizeArray` index getter/setter not throwing `IndexOutOfRangeException` when index is out of bounds (fix #3812) (by @MangelMaxime) * [Beam] Fix unused term warning in try/catch when exception variable is not referenced (by @dbrattli) diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index 41339e79c3..39130296c5 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* [JS/TS/Python] Fix record/struct types augmented with `static let` or `static member val` generating extra constructor parameters for each static field, causing constructor arguments to be assigned to wrong slots (by @MangelMaxime) * [TS] Annotate `System.Collections.Generic.IList` as `MutableArray` (by @MangelMaxime) * [JS/TS] Fix `ResizeArray` index getter/setter not throwing `IndexOutOfRangeException` when index is out of bounds (fix #3812) (by @MangelMaxime) * [Beam] Fix unused term warning in try/catch when exception variable is not referenced (by @dbrattli) diff --git a/src/Fable.Transforms/Fable2Babel.fs b/src/Fable.Transforms/Fable2Babel.fs index 7b56ea98ac..ca54b6c3d5 100644 --- a/src/Fable.Transforms/Fable2Babel.fs +++ b/src/Fable.Transforms/Fable2Babel.fs @@ -3410,10 +3410,13 @@ but thanks to the optimisation done below we get let getEntityFieldsAsIdents (ent: Fable.Entity) = ent.FSharpFields - |> List.map (fun field -> - let name = sanitizeName field.Name - let typ = field.FieldType - { makeTypedIdent typ name with IsMutable = field.IsMutable } + |> List.choose (fun field -> + if field.IsStatic then + None + else + let name = sanitizeName field.Name + let typ = field.FieldType + Some { makeTypedIdent typ name with IsMutable = field.IsMutable } ) let declareClassWithParams @@ -3997,6 +4000,7 @@ but thanks to the optimisation done below we get yield callSuperAsStatement [] yield! ent.FSharpFields + |> List.filter (fun field -> not field.IsStatic) |> List.mapi (fun i field -> let left = get None thisExpr field.Name diff --git a/src/Fable.Transforms/FableTransforms.fs b/src/Fable.Transforms/FableTransforms.fs index 0504a57575..ea5a437069 100644 --- a/src/Fable.Transforms/FableTransforms.fs +++ b/src/Fable.Transforms/FableTransforms.fs @@ -745,7 +745,11 @@ module private Transforms = Emit({ emitInfo with CallInfo = { callInfo with Args = args } }, t, r) // Uncurry also values in setters or new record/union/tuple | Value(NewRecord(args, ent, genArgs), r) -> - let args = com.GetEntity(ent).FSharpFields |> uncurryConsArgs args + let args = + com.GetEntity(ent).FSharpFields + |> Seq.filter (fun f -> not f.IsStatic) + |> uncurryConsArgs args + Value(NewRecord(args, ent, genArgs), r) | Value(NewAnonymousRecord(args, fieldNames, genArgs, isStruct), r) -> let args = uncurryArgs com false genArgs args diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index 7f280d15ec..a2859892cd 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -3197,14 +3197,17 @@ let getEntityFieldsAsIdents (com: IPythonCompiler) (ent: Fable.Entity) = Naming.toPythonNaming ent.FSharpFields - |> Seq.map (fun field -> - let name = - (entityNamingConvention field.Name, Naming.NoMemberPart) - ||> Naming.sanitizeIdent (fun _ -> false) + |> Seq.choose (fun field -> + if field.IsStatic then + None + else + let name = + (entityNamingConvention field.Name, Naming.NoMemberPart) + ||> Naming.sanitizeIdent (fun _ -> false) - let typ = field.FieldType + let typ = field.FieldType - { makeTypedIdent typ name with IsMutable = field.IsMutable } + Some { makeTypedIdent typ name with IsMutable = field.IsMutable } ) |> Seq.toArray @@ -3248,6 +3251,7 @@ let declareDataClassType // lambda types in the type annotations to match. let props = ent.FSharpFields + |> List.filter (fun field -> not field.IsStatic) |> List.mapi (fun i field -> // Get the argument name from consArgs (preserves the Python naming convention) let argName = @@ -4142,6 +4146,7 @@ let transformClassWithCompilerGeneratedConstructor if classAttributes.Style = ClassStyle.Attributes then yield! ent.FSharpFields + |> List.filter (fun field -> not field.IsStatic) |> List.collect (fun field -> let fieldName = field.Name |> Naming.toPropertyNaming let left = get com ctx None thisExpr fieldName false @@ -4155,6 +4160,7 @@ let transformClassWithCompilerGeneratedConstructor else yield! ent.FSharpFields + |> List.filter (fun field -> not field.IsStatic) |> List.collecti (fun i field -> let fieldName = if shouldUseRecordFieldNaming ent then diff --git a/src/quicktest/QuickTest.fs b/src/quicktest/QuickTest.fs index 848758087f..f44253a9ab 100644 --- a/src/quicktest/QuickTest.fs +++ b/src/quicktest/QuickTest.fs @@ -12,90 +12,44 @@ open Fable.Core.JsInterop open Fable.Core.Testing open System.Globalization -let log (o: obj) = JS.console.log (o) -// printfn "%A" o -let equal expected actual = - let areEqual = expected = actual - printfn "%A = %A > %b" expected actual areEqual +// Report #1 - if not areEqual then - failwithf "[ASSERT ERROR] Expected %A but got %A" expected actual +type Items = + { + xs: ResizeArray + } -let throwsError (expected: string) (f: unit -> 'a) : unit = - let success = - try - f () |> ignore - true - with e -> - if not <| String.IsNullOrEmpty(expected) then - equal e.Message expected + static member val empty = { xs = ResizeArray() } - false - // TODO better error messages - equal false success +let a = Items.empty +let b = Items.empty -let testCase (msg: string) f : unit = - try - printfn "%s" msg - f () - with ex -> - printfn "%s" ex.Message +a.xs.Add 1 +b.xs.Add 1 - if - ex.Message <> null - && ex.Message.StartsWith("[ASSERT ERROR]", StringComparison.Ordinal) |> not - then - printfn "%s" (ex.StackTrace ??= "") +printfn $"{a.xs.Count} should match {b.xs.Count}" - printfn "" +// Report #2 -let testCaseAsync msg f = - testCase - msg - (fun () -> - async { - try - do! f () - with ex -> - printfn "%s" ex.Message +type X = + { + id: int + } - if - ex.Message <> null - && ex.Message.StartsWith("[ASSERT ERROR]", StringComparison.Ordinal) |> not - then - printfn "%s" (ex.StackTrace ??= "") - } - |> Async.StartImmediate - ) + static let _a = 2 + static member a = _a -let throwsAnyError (f: unit -> 'a) : unit = - let success = - try - f () |> ignore - true - with e -> - printfn "Got expected error: %s" e.Message - false +// returns 0, since the generated constructor takes three params +// and only one is provided when a static let is presnet +let test1 = { id = 1 } +JS.console.log (test1.id = 1) - if success then - printfn "[ERROR EXPECTED]" +type Y(id: int) = + static let _a = 2 + static member a = 2 + member _.id = id -let measureTime (f: unit -> unit) : unit = - emitJsStatement - () - """ - //js - const startTime = process.hrtime(); - f(); - const elapsed = process.hrtime(startTime); - console.log("Ms:", elapsed[0] * 1e3 + elapsed[1] / 1e6); - //!js -""" - -printfn "Running quick tests..." - -// Write here your unit test, you can later move it -// to Fable.Tests project. For example: -// testCase "Addition works" <| fun () -> -// 2 + 2 |> equal 4 +// returns 1, as expected, since the constructor takes a single parameter +let test2 = Y(id = 1) +JS.console.log (test2.id = 1) diff --git a/tests/Beam/RecordTests.fs b/tests/Beam/RecordTests.fs index 685e89ee6c..26d00d37fb 100644 --- a/tests/Beam/RecordTests.fs +++ b/tests/Beam/RecordTests.fs @@ -279,3 +279,28 @@ let ``test Records with ref-type interior mutability`` () = x.MutRefValue |> equal "ab" x.MutRefValue <- x.MutRefValue + "c" x.MutRefValue |> equal "abc" + +// Issue #3923 - static let on a record shifts constructor arg indices +type RecordWithStaticLet = + { id: int } + static let _a = 2 + static member a = _a + +// Issue #4091 - static member val on a record causes shared mutable state +type RecordWithStaticMemberVal = + { xs: ResizeArray } + static member val empty = { xs = ResizeArray() } + +[] +let ``test Record with static let has correct constructor`` () = + let x = { id = 1 } + x.id |> equal 1 + RecordWithStaticLet.a |> equal 2 + +[] +let ``test Record with static member val does not share state across instances`` () = + let a = RecordWithStaticMemberVal.empty + let b = RecordWithStaticMemberVal.empty + a.xs.Add 1 + a.xs.Count |> equal 1 + b.xs.Count |> equal 1 diff --git a/tests/Js/Main/RecordTypeTests.fs b/tests/Js/Main/RecordTypeTests.fs index 3a5f7ebee6..13d52494f2 100644 --- a/tests/Js/Main/RecordTypeTests.fs +++ b/tests/Js/Main/RecordTypeTests.fs @@ -45,6 +45,17 @@ type Time = type CarInterior = { Seats: int } type Car = { Interior: CarInterior } +// Issue #3923 - static let on a record shifts constructor arg indices +type RecordWithStaticLet = + { id: int } + static let _a = 2 + static member a = _a + +// Issue #4091 - static member val on a record causes shared mutable state +type RecordWithStaticMemberVal = + { xs: ResizeArray } + static member val empty = { xs = ResizeArray() } + let tests = testList "RecordTypes" [ @@ -160,4 +171,18 @@ let tests = {| car with Interior.Seats = 5 |} equal 5 car2.Interior.Seats + // Issue #3923 + testCase "Record with static let has correct constructor" <| fun () -> + let x = { id = 1 } + equal 1 x.id + equal 2 RecordWithStaticLet.a + + // Issue #4091 + testCase "Record with static member val does not share state across instances" <| fun () -> + let a = RecordWithStaticMemberVal.empty + let b = RecordWithStaticMemberVal.empty + a.xs.Add 1 + equal 1 a.xs.Count + equal 1 b.xs.Count + ] diff --git a/tests/Python/TestRecordType.fs b/tests/Python/TestRecordType.fs index ed4cdca066..df1c867157 100644 --- a/tests/Python/TestRecordType.fs +++ b/tests/Python/TestRecordType.fs @@ -42,6 +42,17 @@ type Time = type CarInterior = { Seats: int } type Car = { Interior: CarInterior } +// Issue #3923 - static let on a record shifts constructor arg indices +type RecordWithStaticLet = + { id: int } + static let _a = 2 + static member a = _a + +// Issue #4091 - static member val on a record causes shared mutable state +type RecordWithStaticMemberVal = + { xs: ResizeArray } + static member val empty = { xs = ResizeArray() } + type RecordA = { OptionalField : string option } @@ -199,4 +210,20 @@ type RecordWithProperty = [] let ``test Record property access uses correct naming`` () = let x = { items = ["Hello"; "World"] } - equal "Hello - World" x.fullName \ No newline at end of file + equal "Hello - World" x.fullName + +// Issue #3923 +[] +let ``test Record with static let has correct constructor`` () = + let x = { id = 1 } + equal 1 x.id + equal 2 RecordWithStaticLet.a + +// Issue #4091 +[] +let ``test Record with static member val does not share state across instances`` () = + let a = RecordWithStaticMemberVal.empty + let b = RecordWithStaticMemberVal.empty + a.xs.Add 1 + equal 1 a.xs.Count + equal 1 b.xs.Count \ No newline at end of file From 8d06b2f4736694c0a8039f657a7effe151a98008 Mon Sep 17 00:00:00 2001 From: Mangel Maxime Date: Sun, 15 Mar 2026 16:03:06 +0100 Subject: [PATCH 2/5] revert: BEAM tests addition --- tests/Beam/RecordTests.fs | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/tests/Beam/RecordTests.fs b/tests/Beam/RecordTests.fs index 26d00d37fb..685e89ee6c 100644 --- a/tests/Beam/RecordTests.fs +++ b/tests/Beam/RecordTests.fs @@ -279,28 +279,3 @@ let ``test Records with ref-type interior mutability`` () = x.MutRefValue |> equal "ab" x.MutRefValue <- x.MutRefValue + "c" x.MutRefValue |> equal "abc" - -// Issue #3923 - static let on a record shifts constructor arg indices -type RecordWithStaticLet = - { id: int } - static let _a = 2 - static member a = _a - -// Issue #4091 - static member val on a record causes shared mutable state -type RecordWithStaticMemberVal = - { xs: ResizeArray } - static member val empty = { xs = ResizeArray() } - -[] -let ``test Record with static let has correct constructor`` () = - let x = { id = 1 } - x.id |> equal 1 - RecordWithStaticLet.a |> equal 2 - -[] -let ``test Record with static member val does not share state across instances`` () = - let a = RecordWithStaticMemberVal.empty - let b = RecordWithStaticMemberVal.empty - a.xs.Add 1 - a.xs.Count |> equal 1 - b.xs.Count |> equal 1 From a6852822241f3f3cdba412ee2740ff9fbdc97db4 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Sun, 15 Mar 2026 16:49:10 +0100 Subject: [PATCH 3/5] [Python] Fix record reflection info and add ClassVar for static fields Filter static backing fields from record reflection metadata and generate ClassVar annotations on dataclasses so Pyright recognizes static attributes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Python/Fable2Python.Reflection.fs | 19 +++++++------ .../Python/Fable2Python.Transforms.fs | 27 ++++++++++++++++++- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/Fable.Transforms/Python/Fable2Python.Reflection.fs b/src/Fable.Transforms/Python/Fable2Python.Reflection.fs index 5c99228a47..205eb35f25 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Reflection.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Reflection.fs @@ -32,16 +32,19 @@ let private transformRecordReflectionInfo com ctx r (ent: Fable.Entity) generics let fields, stmts = ent.FSharpFields - |> Seq.map (fun fi -> - let typeInfo, stmts = transformTypeInfo com ctx r genMap fi.FieldType + |> Seq.choose (fun fi -> + if fi.IsStatic then + None + else + let typeInfo, stmts = transformTypeInfo com ctx r genMap fi.FieldType - let name = - if Util.shouldUseRecordFieldNaming ent then - fi.Name |> Naming.toRecordFieldSnakeCase |> Helpers.clean - else - fi.Name |> Naming.toSnakeCase |> Helpers.clean + let name = + if Util.shouldUseRecordFieldNaming ent then + fi.Name |> Naming.toRecordFieldSnakeCase |> Helpers.clean + else + fi.Name |> Naming.toSnakeCase |> Helpers.clean - Expression.tuple [ Expression.stringConstant name; typeInfo ], stmts + Some(Expression.tuple [ Expression.stringConstant name; typeInfo ], stmts) ) |> Seq.toList |> Helpers.unzipArgs diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index a2859892cd..e4d791e7b1 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -3347,8 +3347,33 @@ let declareDataClassType returns = Expression.name "int" ) + // Generate ClassVar annotations for static fields so that Pyright recognizes + // the class-level attributes assigned by the static constructor (_cctor). + // With slots=True, unannotated class attributes are not allowed. + let staticFieldAnnotations = + ent.FSharpFields + |> List.choose (fun field -> + if + field.IsStatic + && not (field.Name.StartsWith("init@", System.StringComparison.Ordinal)) + then + let fieldName = field.Name.TrimEnd('@') |> Naming.toPythonNaming + let ta, _ = Annotation.typeAnnotation com ctx None field.FieldType + let classVar = com.GetImportExpr(ctx, "typing", "ClassVar") + let classVarAnnotation = Expression.subscript (classVar, ta) + Some(Statement.annAssign (Expression.name fieldName, annotation = classVarAnnotation)) + else + None + ) + let classBody = - let body = [ yield! props; yield! classMembers; yield hashMethod ] + let body = + [ + yield! staticFieldAnnotations + yield! props + yield! classMembers + yield hashMethod + ] match body with | [] -> [ Statement.ellipsis ] From 58ed8d72f7652d3a69b10b5b8087013a5a72482d Mon Sep 17 00:00:00 2001 From: Mangel Maxime Date: Tue, 17 Mar 2026 19:15:25 +0100 Subject: [PATCH 4/5] revert: quicktest --- src/quicktest/QuickTest.fs | 104 ++++++++++++++++++++++++++----------- 1 file changed, 75 insertions(+), 29 deletions(-) diff --git a/src/quicktest/QuickTest.fs b/src/quicktest/QuickTest.fs index f44253a9ab..848758087f 100644 --- a/src/quicktest/QuickTest.fs +++ b/src/quicktest/QuickTest.fs @@ -12,44 +12,90 @@ open Fable.Core.JsInterop open Fable.Core.Testing open System.Globalization +let log (o: obj) = JS.console.log (o) +// printfn "%A" o -// Report #1 +let equal expected actual = + let areEqual = expected = actual + printfn "%A = %A > %b" expected actual areEqual -type Items = - { - xs: ResizeArray - } + if not areEqual then + failwithf "[ASSERT ERROR] Expected %A but got %A" expected actual - static member val empty = { xs = ResizeArray() } +let throwsError (expected: string) (f: unit -> 'a) : unit = + let success = + try + f () |> ignore + true + with e -> + if not <| String.IsNullOrEmpty(expected) then + equal e.Message expected -let a = Items.empty -let b = Items.empty + false + // TODO better error messages + equal false success -a.xs.Add 1 -b.xs.Add 1 +let testCase (msg: string) f : unit = + try + printfn "%s" msg + f () + with ex -> + printfn "%s" ex.Message -printfn $"{a.xs.Count} should match {b.xs.Count}" + if + ex.Message <> null + && ex.Message.StartsWith("[ASSERT ERROR]", StringComparison.Ordinal) |> not + then + printfn "%s" (ex.StackTrace ??= "") -// Report #2 + printfn "" -type X = - { - id: int - } +let testCaseAsync msg f = + testCase + msg + (fun () -> + async { + try + do! f () + with ex -> + printfn "%s" ex.Message - static let _a = 2 - static member a = _a + if + ex.Message <> null + && ex.Message.StartsWith("[ASSERT ERROR]", StringComparison.Ordinal) |> not + then + printfn "%s" (ex.StackTrace ??= "") + } + |> Async.StartImmediate + ) -// returns 0, since the generated constructor takes three params -// and only one is provided when a static let is presnet -let test1 = { id = 1 } -JS.console.log (test1.id = 1) +let throwsAnyError (f: unit -> 'a) : unit = + let success = + try + f () |> ignore + true + with e -> + printfn "Got expected error: %s" e.Message + false -type Y(id: int) = - static let _a = 2 - static member a = 2 - member _.id = id + if success then + printfn "[ERROR EXPECTED]" -// returns 1, as expected, since the constructor takes a single parameter -let test2 = Y(id = 1) -JS.console.log (test2.id = 1) +let measureTime (f: unit -> unit) : unit = + emitJsStatement + () + """ + //js + const startTime = process.hrtime(); + f(); + const elapsed = process.hrtime(startTime); + console.log("Ms:", elapsed[0] * 1e3 + elapsed[1] / 1e6); + //!js +""" + +printfn "Running quick tests..." + +// Write here your unit test, you can later move it +// to Fable.Tests project. For example: +// testCase "Addition works" <| fun () -> +// 2 + 2 |> equal 4 From 20758ba78831f752ab25d79c0372261fa62aae5c Mon Sep 17 00:00:00 2001 From: Mangel Maxime Date: Tue, 17 Mar 2026 20:41:51 +0100 Subject: [PATCH 5/5] [JS/TS]: Fix record reflection info Apply suggestion from @dbrattli --- src/Fable.Transforms/Fable2Babel.fs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Fable.Transforms/Fable2Babel.fs b/src/Fable.Transforms/Fable2Babel.fs index ca54b6c3d5..7433a19e51 100644 --- a/src/Fable.Transforms/Fable2Babel.fs +++ b/src/Fable.Transforms/Fable2Babel.fs @@ -176,12 +176,15 @@ module Reflection = let fields = ent.FSharpFields - |> List.map (fun fi -> - let fieldName = sanitizeMemberName fi.Name |> Expression.stringLiteral + |> List.choose (fun fi -> + if fi.IsStatic then + None + else + let fieldName = sanitizeMemberName fi.Name |> Expression.stringLiteral - let typeInfo = transformTypeInfoFor Reflection com ctx r genMap fi.FieldType + let typeInfo = transformTypeInfoFor Reflection com ctx r genMap fi.FieldType - Expression.arrayExpression ([| fieldName; typeInfo |]) + Some(Expression.arrayExpression ([| fieldName; typeInfo |])) ) |> List.toArray