From b409b0076263b42754eb064c6c5186119c351886 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 20 Mar 2026 01:40:32 +0000 Subject: [PATCH 1/3] Fix TCO context adding unnecessary default params to nested lambdas (fix #3877) When a function is tail-call optimised, all nested functions/lambdas created within its body previously inherited all TCO variables as default parameters (e.g. `_arg: Any=_arg`), even when the nested function doesn't reference those variables at all. Root cause: `transformFunction` computes `tcArgs` from the outer `TailCallOpportunity` in context and appends them unconditionally to every nested function. The capture is needed for closures that *do* reference a mutated TCO variable (Python captures by reference, so defaults bind the current value). But it was applied indiscriminately. Fix: before appending a TCO arg as a default, check whether the function body actually references that variable using `isIdentUsed`. Variables not referenced in the body are excluded from the captured defaults. This eliminates spurious parameters like `_arg: Any=_arg` on lambdas like `fun () -> g()` that don't use the outer TCO variable, and keeps the capture for lambdas like `fun () -> i` that do. Co-Authored-By: Claude Sonnet 4.6 --- src/Fable.Cli/CHANGELOG.md | 4 ++++ src/Fable.Compiler/CHANGELOG.md | 4 ++++ .../Python/Fable2Python.Transforms.fs | 6 ++++++ tests/Python/TestTailCall.fs | 14 ++++++++++++++ 4 files changed, 28 insertions(+) diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index 0277caea5d..bc4c65711c 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +* [Python] Fix function references passed as arguments inside tail-call optimised functions gaining unnecessary default parameters for outer TCO variables they don't reference (fix #3877) + ## 5.0.0-rc.4 - 2026-03-19 ### Fixed diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index 1704948880..bccbd5ad53 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +* [Python] Fix function references passed as arguments inside tail-call optimised functions gaining unnecessary default parameters for outer TCO variables they don't reference (fix #3877) + ## 5.0.0-rc.10 - 2026-03-19 ### Fixed diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index e4d791e7b1..81d2e53ad0 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -3002,6 +3002,12 @@ let transformFunction let (Identifier name) = arg.Arg let name = cleanName name + // Only capture TCO variables actually referenced in the function body. + // This avoids unnecessary default parameters on nested lambdas that don't + // use the outer TCO variables. See #3877. + if not (isIdentUsed name body) then None + else + match name with | "tupled_arg_m" -> None // Remove these arguments (not sure why) | _ -> diff --git a/tests/Python/TestTailCall.fs b/tests/Python/TestTailCall.fs index 29894849f2..0311682d15 100644 --- a/tests/Python/TestTailCall.fs +++ b/tests/Python/TestTailCall.fs @@ -292,3 +292,17 @@ let ``test Expressiones captured in a closure don't change after tail-call optim main init view update accValue |> equal 10 + +// See https://github.com/fable-compiler/Fable/issues/3877 +[] +let ``test Passing a function reference in a TCO context does not capture unneeded outer args`` () = + let applyFn (f: unit -> int) = f() + let alwaysFortyTwo () = 42 + + // `alwaysFortyTwo` doesn't reference `_unused`, so it should not capture it + // even though `loop` is tail-call optimised with `_unused` in scope. + let rec loop (_unused: string) acc n = + if n <= 0 then acc + else loop _unused (acc + applyFn alwaysFortyTwo) (n - 1) + + loop "ignored" 0 5 |> equal 210 From ef28c55071179b7f922c09d69830dea854a06e56 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Sat, 21 Mar 2026 10:45:22 +0100 Subject: [PATCH 2/3] Formatted --- .../Python/Fable2Python.Transforms.fs | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index c1028d96f7..87688308c6 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -3006,25 +3006,26 @@ let transformFunction // Only capture TCO variables actually referenced in the function body. // This avoids unnecessary default parameters on nested lambdas that don't // use the outer TCO variables. See #3877. - if not (isIdentUsed name body) then None + if not (isIdentUsed name body) then + None else - match name with - | "tupled_arg_m" -> None // Remove these arguments (not sure why) - | _ -> - let annotation = - // Cleanup type annotations to avoid non-repeated generics - match arg.Annotation with - | Some(Expression.Name { Id = Identifier _name }) -> arg.Annotation - | Some(Expression.Subscript { - Value = value - Slice = Expression.Name { Id = Identifier name } - }) when name.StartsWith("_", StringComparison.Ordinal) -> - Expression.subscript (value, stdlibModuleAnnotation com ctx "typing" "Any" []) - |> Some - | _ -> Some(stdlibModuleAnnotation com ctx "typing" "Any" []) - - (Arg.arg (name, ?annotation = annotation), Expression.name name) |> Some + match name with + | "tupled_arg_m" -> None // Remove these arguments (not sure why) + | _ -> + let annotation = + // Cleanup type annotations to avoid non-repeated generics + match arg.Annotation with + | Some(Expression.Name { Id = Identifier _name }) -> arg.Annotation + | Some(Expression.Subscript { + Value = value + Slice = Expression.Name { Id = Identifier name } + }) when name.StartsWith("_", StringComparison.Ordinal) -> + Expression.subscript (value, stdlibModuleAnnotation com ctx "typing" "Any" []) + |> Some + | _ -> Some(stdlibModuleAnnotation com ctx "typing" "Any" []) + + (Arg.arg (name, ?annotation = annotation), Expression.name name) |> Some ) |> List.unzip | _ -> [], [] From 98fc7cbc043b5cd4413ea65ebab094bf87674f17 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Sat, 21 Mar 2026 11:52:35 +0100 Subject: [PATCH 3/3] Flatten the if-else into the match --- .../Python/Fable2Python.Transforms.fs | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index 87688308c6..2ec963ca0c 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -3003,29 +3003,26 @@ let transformFunction let (Identifier name) = arg.Arg let name = cleanName name + match name with + | "tupled_arg_m" -> None // Remove these arguments (not sure why) // Only capture TCO variables actually referenced in the function body. // This avoids unnecessary default parameters on nested lambdas that don't // use the outer TCO variables. See #3877. - if not (isIdentUsed name body) then - None - else - - match name with - | "tupled_arg_m" -> None // Remove these arguments (not sure why) - | _ -> - let annotation = - // Cleanup type annotations to avoid non-repeated generics - match arg.Annotation with - | Some(Expression.Name { Id = Identifier _name }) -> arg.Annotation - | Some(Expression.Subscript { - Value = value - Slice = Expression.Name { Id = Identifier name } - }) when name.StartsWith("_", StringComparison.Ordinal) -> - Expression.subscript (value, stdlibModuleAnnotation com ctx "typing" "Any" []) - |> Some - | _ -> Some(stdlibModuleAnnotation com ctx "typing" "Any" []) - - (Arg.arg (name, ?annotation = annotation), Expression.name name) |> Some + | _ when not (isIdentUsed name body) -> None + | _ -> + let annotation = + // Cleanup type annotations to avoid non-repeated generics + match arg.Annotation with + | Some(Expression.Name { Id = Identifier _name }) -> arg.Annotation + | Some(Expression.Subscript { + Value = value + Slice = Expression.Name { Id = Identifier name } + }) when name.StartsWith("_", StringComparison.Ordinal) -> + Expression.subscript (value, stdlibModuleAnnotation com ctx "typing" "Any" []) + |> Some + | _ -> Some(stdlibModuleAnnotation com ctx "typing" "Any" []) + + (Arg.arg (name, ?annotation = annotation), Expression.name name) |> Some ) |> List.unzip | _ -> [], []