Skip to content

JNI: non-escaping closures taking or returning jextracted types - #20

Open
piercifani wants to merge 2 commits into
jni/15-nullable-optionalsfrom
jni/16-closure-struct-arguments
Open

piercifani wants to merge 2 commits into
jni/15-nullable-optionalsfrom
jni/16-closure-struct-arguments

Conversation

@piercifani

@piercifani piercifani commented Nov 3, 2026 •

Copy link
Copy Markdown

Stack 16/21 · base: jni/15-nullable-optionals · previous: #19 · next: #21 (jni/17-closure-results)

Problem

A non-escaping closure whose arguments or result are jextracted types was skipped: func transform(_ item: Item, _ f: (Item) -> Item) -> Item didn't reach Java. The non-escaping closure lowering converts primitives only.

Changes

When the non-escaping lowering can't translate a closure, it now goes through the escaping one. Its @JavaInterface wrapper already converts jextracted types, optionals of them and strings, and the Swift closure it builds can be passed where a non-escaping one is expected.

  • Swift: the immediately-called literal that builds the closure spells out its type, { () -> (Item) -> Item in … }(), for a non-escaping parameter. Inferred from that parameter, the returned closure would be non-escaping, and returning it wouldn't compile.
  • Java: callers see the same functional interface as before, <Type>.<method>.<parameter>, with jextracted types in its apply.
  • Unchanged: closures the non-escaping lowering already handled, such as primitive ones and those matching a JDK functional interface, keep it.

The escaping path costs a global reference per call to hold the Java object for as long as the Swift closure lives, here for the duration of the call.

Kotlin in this PR

ClosuresTest.kt with Closures.swift:

// public func transform(id: String, _ f: (Item) -> Item) -> Item
val out = Notifier.init().transform("a") { item -> Item.init(item.id + "!", item.count + 1) }
assertEquals("a!", out.id)

Why Skip needs it

Skip passes Kotlin lambdas for Swift closure parameters whatever their types, and non-escaping is Swift's default, so closures over the app's own structs are common.

Testing

  • Kotlin sample: ./gradlew :Samples:SwiftJavaExtractJNIKotlinSampleApp:test passes (43 tests).
  • JNIEscapingClosureJExtractedTypesTests: two new tests check the @JavaInterface wrapper and the spelled-out closure type for a non-escaping (Item) -> Item, and the Java interface.
  • JNI sample: ClosuresTest.nonEscapingStructClosure passes a struct-returning lambda to transformStruct.
  • Full swift test --disable-experimental-prebuilts --disable-sandbox (675 tests) and ./gradlew :Samples:SwiftJavaExtractJNISampleApp:test (294 tests) pass.
  • swift format lint --strict on changed files; git diff --check.

Not in this PR

🤖 Generated with Claude Code

piercifani and others added 2 commits November 3, 2026 20:12
The non-escaping closure lowering converts primitive arguments and
results only, so a function taking `(Item) -> Item` was skipped. When
that lowering can't translate a non-escaping closure, it now goes
through the escaping one, whose `@JavaInterface` wrapper already
converts jextracted types, optionals of them and strings. Its Swift
closure is passed where a non-escaping one is expected.

The immediately-called literal that builds it spells out its closure
type in that case: inferred from the non-escaping parameter, it couldn't
return the closure. Java callers see the same functional interface as
before, `<Type>.<method>.<parameter>`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant