From 69f1ee494d17b618bc9fb3eda8d0a4adc94329de Mon Sep 17 00:00:00 2001 From: vincentborko Date: Wed, 3 Jun 2026 00:49:23 +0200 Subject: [PATCH] Compose statement-level View operators (e.g. Text + Text) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A statement-level operator expression that evaluates to a View — most notably `Text + Text` concatenation — was built but never composed: the ViewBuilder transform only added a `.Compose(composectx)` tail call to APICallExpression statements, so a bare `Text("a") + Text("b")` was constructed and discarded (no Compose node, zero size). Wrapping it in `.frame(...)` "fixed" it only because the outer call then got the tail call. Add a tail call for statement-level binary-operator expressions whose inferred result type conforms to View, wrapping the operator in parentheses so `.Compose` applies to the whole result rather than its right-hand operand: `(Text("a") + Text("b")).Compose(composectx)`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Kotlin/KotlinExpressionTypes.swift | 4 ++ .../Kotlin/KotlinSwiftUITransformer.swift | 16 +++++++ Tests/SkipSyntaxTests/SwiftUITests.swift | 42 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/Sources/SkipSyntax/Kotlin/KotlinExpressionTypes.swift b/Sources/SkipSyntax/Kotlin/KotlinExpressionTypes.swift index cea0452e..4378fa14 100644 --- a/Sources/SkipSyntax/Kotlin/KotlinExpressionTypes.swift +++ b/Sources/SkipSyntax/Kotlin/KotlinExpressionTypes.swift @@ -194,6 +194,9 @@ final class KotlinBinaryOperator: KotlinExpression, KotlinSingleStatementVetoing var lhs: KotlinExpression var rhs: KotlinExpression var mayBeSharedMutableStruct = false + /// The inferred result type of the operator expression, used e.g. to decide whether a statement-level + /// operator that produces a View (such as `Text + Text`) needs a Compose tail call. + var inferredType: TypeSignature = .none static func translate(expression: BinaryOperator, translator: KotlinTranslator) -> KotlinExpression { // Special case when assigning to _ @@ -209,6 +212,7 @@ final class KotlinBinaryOperator: KotlinExpression, KotlinSingleStatementVetoing let kexpression = KotlinBinaryOperator(expression: expression, lhs: klhs, rhs: krhs) kexpression.mayBeSharedMutableStruct = expression.inferredType.kotlinMayBeSharedMutableStruct(codebaseInfo: translator.codebaseInfo) + kexpression.inferredType = expression.inferredType.resolvingSelf(in: expression) switch expression.op.symbol { case "<<=", ">>=", "&=", "|=", "^=", "~=": diff --git a/Sources/SkipSyntax/Kotlin/KotlinSwiftUITransformer.swift b/Sources/SkipSyntax/Kotlin/KotlinSwiftUITransformer.swift index 99666b94..22907443 100644 --- a/Sources/SkipSyntax/Kotlin/KotlinSwiftUITransformer.swift +++ b/Sources/SkipSyntax/Kotlin/KotlinSwiftUITransformer.swift @@ -720,6 +720,22 @@ private final class TranslateVisitor { } else { return .recurse(nil) } + } else if let binaryOperator = node as? KotlinBinaryOperator { + var parent = node.parent + if parent is KotlinSRef { + parent = parent?.parent + } + // A statement-level operator expression that evaluates to a View — most notably + // `Text + Text` concatenation — is built into the TupleView like any other view and + // must get a Compose tail call. Wrap it in parentheses so `.Compose` applies to the + // whole operator result rather than binding to its right-hand operand. + if let expressionStatement = parent as? KotlinExpressionStatement, !isInAssignmentExpression(expressionStatement, in: codeBlock), + isSwiftUIType(named: "View", type: binaryOperator.inferredType, codebaseInfo: translator.codebaseInfo) { + addComposeTailCall(to: KotlinParenthesized(content: binaryOperator), statement: expressionStatement) + return .skip + } else { + return .recurse(nil) + } } else { return .recurse(nil) } diff --git a/Tests/SkipSyntaxTests/SwiftUITests.swift b/Tests/SkipSyntaxTests/SwiftUITests.swift index 290697dc..d1174697 100644 --- a/Tests/SkipSyntaxTests/SwiftUITests.swift +++ b/Tests/SkipSyntaxTests/SwiftUITests.swift @@ -2884,4 +2884,46 @@ final class SwiftUITests: XCTestCase { } """) } + + func testViewProducingOperatorTailCall() async throws { + // A statement-level operator expression that evaluates to a View (e.g. `Text + Text` + // concatenation) must get a Compose tail call, wrapped in parentheses so `.Compose` + // applies to the whole operator result rather than its right-hand operand. + let supportingSwift = baseSupportingSwift + """ + extension Text { + // SKIP DECLARE: operator fun plus(other: Text): Text + func plus(other: Text) -> Text { + } + } + """ + + try await check(supportingSwift: supportingSwift, swift: """ + import SwiftUI + func f() { + VStack { + Text("a") + Text("b") + } + } + """, kotlin: """ + import androidx.compose.runtime.Composable + import androidx.compose.runtime.getValue + import androidx.compose.runtime.mutableStateOf + import androidx.compose.runtime.remember + import androidx.compose.runtime.saveable.Saver + import androidx.compose.runtime.saveable.rememberSaveable + import androidx.compose.runtime.setValue + + import skip.ui.* + import skip.foundation.* + import skip.model.* + internal fun f() { + VStack { -> + ComposeBuilder { composectx: ComposeContext -> + (Text("a") + Text("b")).Compose(composectx) + ComposeResult.ok + } + } + } + """) + } }