From 44a1950b0d9bb646b223184f2b6c7d154565dd6d Mon Sep 17 00:00:00 2001 From: Mangel Maxime Date: Tue, 8 Sep 2026 07:29:17 +0200 Subject: [PATCH] fix: dereference structs passed by reference --- src/Fable.Transforms/FSharp2Fable.Util.fs | 17 +++++++++------ src/Fable.Transforms/FSharp2Fable.fs | 23 ++++++++++++++++++-- src/Fable.Transforms/Replacements.Util.fs | 6 +++--- tests/Js/Main/TypeTests.fs | 25 ++++++++++++++++++++++ tests/Python/TestType.fs | 26 +++++++++++++++++++++++ 5 files changed, 86 insertions(+), 11 deletions(-) diff --git a/src/Fable.Transforms/FSharp2Fable.Util.fs b/src/Fable.Transforms/FSharp2Fable.Util.fs index acbd96349e..7abb3f5fec 100644 --- a/src/Fable.Transforms/FSharp2Fable.Util.fs +++ b/src/Fable.Transforms/FSharp2Fable.Util.fs @@ -832,10 +832,19 @@ module Helpers = // typ.TypeDefinition.DisplayName = "inref" || // typ.TypeDefinition.DisplayName = "outref") + /// F# types the locals it generates for copy-and-update expressions as byrefs, but Fable + /// binds the struct value itself to them. + let isCopyUpdateLocal (value: FSharpMemberOrFunctionOrValue) = + value.IsCompilerGenerated + && (value.CompiledName = "copyOfStruct" || value.CompiledName = "inputRecord") + let isByRefValue (value: FSharpMemberOrFunctionOrValue) = // Value type "this" is passed as inref, so it has to be excluded // (Note: the non-abbreviated type of inref and outref is byref) - value.IsValue && not value.IsMemberThisValue && isByRefType value.FullType + value.IsValue + && not value.IsMemberThisValue + && not (isCopyUpdateLocal value) + && isByRefType value.FullType let tryFindAttrib fullName (attributes: FSharpAttribute seq) = attributes @@ -1888,11 +1897,7 @@ module Identifiers = open TypeHelpers let isMutableOrByRefValue (fsRef: FSharpMemberOrFunctionOrValue) = - (fsRef.IsMutable || isByRefValue fsRef) - && not ( - fsRef.IsCompilerGenerated - && (fsRef.CompiledName = "copyOfStruct" || fsRef.CompiledName = "inputRecord") - ) + not (isCopyUpdateLocal fsRef) && (fsRef.IsMutable || isByRefValue fsRef) let makeIdentFrom (com: IFableCompiler) (ctx: Context) (fsRef: FSharpMemberOrFunctionOrValue) : Fable.Ident = let part = Naming.NoMemberPart diff --git a/src/Fable.Transforms/FSharp2Fable.fs b/src/Fable.Transforms/FSharp2Fable.fs index ddbabedd80..d649ed06a9 100644 --- a/src/Fable.Transforms/FSharp2Fable.fs +++ b/src/Fable.Transforms/FSharp2Fable.fs @@ -330,9 +330,28 @@ let private transformTraitCall |> addErrorAndReturnNull com ctx.InlinePath r ) +/// F# keeps the byref type on a use that goes through the address, like reading a field of a +/// struct passed by reference. Fable compiles byrefs to ref cells, which cannot be addressed +/// that way, so such a use has to read the cell. +let private derefByRefAddress (com: IFableCompiler) (fsExpr: FSharpExpr) (expr: Fable.Expr) = + match fsExpr with + | FSharpExprPatterns.Value var when isByRefValue var && isByRefType fsExpr.Type && com.Options.Language <> Rust -> + Replacements.Api.getRefCell com expr.Range (List.head expr.Type.Generics) expr + | _ -> expr + +let private transformCalleeExprOpt com ctx (callee: FSharpExpr option) = + trampoline { + let! calleeExpr = transformExprOpt com ctx callee + + return + match callee, calleeExpr with + | Some fsCallee, Some calleeExpr -> Some(derefByRefAddress com fsCallee calleeExpr) + | _ -> calleeExpr + } + let private transformCallee com ctx callee (calleeType: FSharpType) = trampoline { - let! callee = transformExprOpt com ctx callee + let! callee = transformCalleeExprOpt com ctx callee let callee = match callee with @@ -1068,7 +1087,7 @@ let private transformExpr (com: IFableCompiler) (ctx: Context) appliedGenArgs fs | callee, _ -> let r = makeRangeFrom fsExpr - let! callee = transformExprOpt com ctx callee + let! callee = transformCalleeExprOpt com ctx callee let! ctx = trampoline { diff --git a/src/Fable.Transforms/Replacements.Util.fs b/src/Fable.Transforms/Replacements.Util.fs index f97e14f0b5..57f6901536 100644 --- a/src/Fable.Transforms/Replacements.Util.fs +++ b/src/Fable.Transforms/Replacements.Util.fs @@ -981,10 +981,10 @@ let (|IsByRefType|_|) (com: Compiler) = | _ -> ValueNone | _ -> ValueNone -/// F# types the locals it generates for copy-and-update expressions (`inputRecord`, `copyOfStruct`) -/// as byrefs, but Fable binds the struct value itself to them. +/// F# types a struct's `this` argument and the locals it generates for copy-and-update +/// expressions (`inputRecord`, `copyOfStruct`) as byrefs, but Fable binds the value itself to them. let getIdentValueType (com: Compiler) (ident: Ident) (typ: Type) = - if ident.IsCompilerGenerated then + if ident.IsCompilerGenerated || ident.IsThisArgument then match typ with | IsByRefType com innerType -> innerType | typ -> typ diff --git a/tests/Js/Main/TypeTests.fs b/tests/Js/Main/TypeTests.fs index 6de95523ff..0fff0ee048 100644 --- a/tests/Js/Main/TypeTests.fs +++ b/tests/Js/Main/TypeTests.fs @@ -765,6 +765,21 @@ let inline inlinedFunc(n: 't[]) = let genericByrefFunc(n: byref<'t[]>) = inlinedFunc n +[] +type ByRefStruct = + { Size: int; Seed: int } + member this.Sum() = this.Size + this.Seed + member this.Total = this.Size + this.Seed + +[] +type MutableByRefStruct = { mutable Count: int } + +let readStructByRef (data: byref) = data.Size + data.Seed +let callStructByRefMember (data: byref) = data.Sum() +let readStructByRefProperty (data: byref) = data.Total +let copyUpdateStructByRef (data: byref) = { data with Size = data.Size + 10 } +let bumpStructByRef (data: byref) = data.Count <- data.Count + 5 + [] type GenericClassWithStaticMember<'T>() = static member Length(xs: 'T list) = xs.Length @@ -1693,6 +1708,16 @@ let tests = let result = genericByrefFunc &arr result |> equal 3 + testCase "Struct passed by reference works" <| fun () -> + let mutable data: ByRefStruct = { Size = 1; Seed = 2 } + readStructByRef &data |> equal 3 + callStructByRefMember &data |> equal 3 + readStructByRefProperty &data |> equal 3 + (copyUpdateStructByRef &data).Size |> equal 11 + let mutable counter: MutableByRefStruct = { Count = 7 } + bumpStructByRef &counter + counter.Count |> equal 12 + testCase "mangled method on interface works" <| fun () -> let upper = UpperMangledClass() diff --git a/tests/Python/TestType.fs b/tests/Python/TestType.fs index 29e5416dcb..5872d7bf8a 100644 --- a/tests/Python/TestType.fs +++ b/tests/Python/TestType.fs @@ -705,6 +705,21 @@ type Model = unit let update (model: Model) = model, () +[] +type ByRefStruct = + { Size: int; Seed: int } + member this.Sum() = this.Size + this.Seed + member this.Total = this.Size + this.Seed + +[] +type MutableByRefStruct = { mutable Count: int } + +let readStructByRef (data: byref) = data.Size + data.Seed +let callStructByRefMember (data: byref) = data.Sum() +let readStructByRefProperty (data: byref) = data.Total +let copyUpdateStructByRef (data: byref) = { data with Size = data.Size + 10 } +let bumpStructByRef (data: byref) = data.Count <- data.Count + 5 + [] let ``test Unit arguments work`` () = update () |> equal ((), ()) @@ -1869,3 +1884,14 @@ let ``test Super call works correctly in multi-level generic class hierarchy`` ( obj.Attach("hello") // Each override delegates to base before logging, so the chain unwinds Base -> Mid -> Leaf log |> List.ofSeq |> equal [ "Base"; "Mid"; "Leaf" ] + +[] +let ``test Struct passed by reference works`` () = + let mutable data: ByRefStruct = { Size = 1; Seed = 2 } + readStructByRef &data |> equal 3 + callStructByRefMember &data |> equal 3 + readStructByRefProperty &data |> equal 3 + (copyUpdateStructByRef &data).Size |> equal 11 + let mutable counter: MutableByRefStruct = { Count = 7 } + bumpStructByRef &counter + counter.Count |> equal 12